Compare commits
327 Commits
fb89e02b02
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfc45118e4 | ||
|
|
1856943925 | ||
|
|
ce9fadc0b5 | ||
|
|
25ee623c42 | ||
|
|
41da124a31 | ||
|
|
77100b6b3b | ||
|
|
32daa4a602 | ||
|
|
b41a78ec29 | ||
|
|
9ea60701d2 | ||
|
|
5a592c4be6 | ||
|
|
7196aab31f | ||
|
|
fec2fe2dda | ||
|
|
3afe29d721 | ||
|
|
f3f8af4b11 | ||
|
|
c3493a3a74 | ||
|
|
ac2f1d824e | ||
|
|
53f4e2de0f | ||
|
|
99dc08488b | ||
|
|
26c4e5771b | ||
|
|
1e5b3a6c3e | ||
|
|
59d72635da | ||
|
|
7a88e8a848 | ||
|
|
b84716ff9c | ||
|
|
ce879f6f70 | ||
|
|
2f7f00d4cc | ||
|
|
6d0973c67c | ||
|
|
bb8b3e235a | ||
|
|
6e3947c0b1 | ||
|
|
128fb7d4d2 | ||
|
|
3af8fb9aa0 | ||
|
|
5b15e30b8a | ||
|
|
e5bce07719 | ||
|
|
9c638e72b1 | ||
|
|
c43b06d83d | ||
|
|
d4674cd74e | ||
|
|
e4d958dcf3 | ||
|
|
0f41384fa8 | ||
|
|
50b1589b23 | ||
|
|
1c689a8472 | ||
|
|
4877c11aa2 | ||
|
|
03617ee3cd | ||
|
|
7869c2a979 | ||
|
|
ce79a2d0fe | ||
|
|
09a930e28e | ||
|
|
c1c7862672 | ||
|
|
19f22d2d97 | ||
|
|
12668f684f | ||
|
|
967e0cd319 | ||
|
|
2223839595 | ||
|
|
7d61d38a34 | ||
|
|
e55367af67 | ||
|
|
0b19ea739c | ||
|
|
3587703fe8 | ||
|
|
7e3ae704fe | ||
|
|
232d7cb647 | ||
|
|
6c8048d0be | ||
|
|
6670771040 | ||
|
|
bc15c16e44 | ||
|
|
ca71275fc4 | ||
|
|
8f4e37ef56 | ||
|
|
789094fcd9 | ||
|
|
9f70f6747e | ||
|
|
182a9df7f3 | ||
|
|
79131f83c1 | ||
|
|
b888a5f0cd | ||
|
|
046da0fd81 | ||
|
|
b095a29f97 | ||
|
|
ce30d01b72 | ||
|
|
89f6b836ba | ||
|
|
b944597af4 | ||
|
|
5da69ee6aa | ||
|
|
5308ba3136 | ||
|
|
a62ef240d1 | ||
|
|
623ebf147b | ||
|
|
8d34db3f9b | ||
|
|
0d55002e5e | ||
|
|
d094a21e09 | ||
|
|
e68bb737e3 | ||
|
|
a6608bf8b3 | ||
|
|
df66c4af46 | ||
|
|
4c92da55ad | ||
|
|
d4d5a4b8e7 | ||
|
|
9ba238f4ad | ||
|
|
c1856657b5 | ||
|
|
47b07373af | ||
|
|
121e8cd476 | ||
|
|
cfbe2fd7e3 | ||
|
|
5079a5fc5c | ||
|
|
618235d8ed | ||
|
|
bca8c9e4cb | ||
|
|
8b02b63d3d | ||
|
|
f890fa85b9 | ||
|
|
fd5562b6e8 | ||
|
|
71c6c68c84 | ||
|
|
507f59f1d1 | ||
|
|
13c280f6d5 | ||
|
|
09e3e7e8b5 | ||
|
|
975db8ab54 | ||
|
|
f383645360 | ||
|
|
4e90828653 | ||
|
|
a335a3b684 | ||
|
|
0b90df6ff0 | ||
|
|
6c9ccf68b6 | ||
|
|
2ff0971dce | ||
|
|
8eafa71ed3 | ||
|
|
dc3fc443b4 | ||
|
|
ff7c239959 | ||
|
|
4ab906ff0b | ||
|
|
064a903076 | ||
|
|
8823265e5a | ||
|
|
cf7a6e413c | ||
|
|
7b737e6717 | ||
|
|
43af17e546 | ||
|
|
5c55f6c6cf | ||
|
|
bdb709b264 | ||
|
|
2d7f825ff3 | ||
|
|
721c36a66b | ||
|
|
10b2ca817b | ||
|
|
1b9f2d4de1 | ||
|
|
59dc1e2357 | ||
|
|
31a394e694 | ||
|
|
d99cb68afb | ||
|
|
1a74e1c058 | ||
|
|
e6846b7e6d | ||
|
|
e767d57640 | ||
|
|
25493528de | ||
|
|
14cc9fb891 | ||
|
|
7f96ae9508 | ||
|
|
6c54759aa0 | ||
|
|
615c1da665 | ||
|
|
e192285f5d | ||
|
|
a6ca1c0108 | ||
|
|
8f94dddbc5 | ||
|
|
45320427e8 | ||
|
|
16e1ddd129 | ||
|
|
288d2ece8b | ||
|
|
2ad6f20258 | ||
|
|
b2eb5fcfa4 | ||
|
|
8e9f09a8e6 | ||
|
|
ce23f64dc3 | ||
|
|
3008c36921 | ||
|
|
e58cac24e1 | ||
|
|
b9896399fa | ||
|
|
7d87c03cfa | ||
|
|
ef070ddab5 | ||
|
|
3142ba203f | ||
|
|
bc788e1e0f | ||
|
|
a6ebff3f34 | ||
|
|
389d9045d5 | ||
|
|
1aead9dad0 | ||
|
|
9d04d1d9f6 | ||
|
|
4c6fd9f024 | ||
|
|
2cab33d708 | ||
|
|
a1727b647c | ||
|
|
6bdfa73150 | ||
|
|
ada4d9fd9b | ||
|
|
6d460ea996 | ||
|
|
bc0f1e3122 | ||
|
|
63759ee7dc | ||
|
|
62106ff644 | ||
|
|
e77ba35b0e | ||
|
|
8afbf20613 | ||
|
|
5a03dc8430 | ||
|
|
e62485db3b | ||
|
|
a5ebfd12f8 | ||
|
|
2262ab0e13 | ||
|
|
0da527dbbc | ||
|
|
9beda55681 | ||
|
|
6800852ae4 | ||
|
|
48899b3df8 | ||
|
|
fce91bcf86 | ||
|
|
975e1ce50c | ||
|
|
1d61df8160 | ||
|
|
1370bf3dcc | ||
|
|
f2db5f4ad0 | ||
|
|
fd2ac4842f | ||
|
|
4de2deaebe | ||
|
|
b7c60f5838 | ||
| e455d85578 | |||
|
|
0782ba574b | ||
|
|
7b67e35720 | ||
|
|
c048264b95 | ||
|
|
6cb20a9213 | ||
|
|
99c6a71e4c | ||
|
|
0088d6e0e0 | ||
|
|
b115a4c512 | ||
|
|
9e09ae6b4e | ||
|
|
43a3740980 | ||
|
|
d28164caf4 | ||
|
|
77f7cf1423 | ||
|
|
84e6c2d5fc | ||
|
|
84b0ba8670 | ||
|
|
b6bec1e63c | ||
|
|
b32621a4e5 | ||
| 993851009b | |||
|
|
450e685580 | ||
|
|
0e116bec7b | ||
|
|
47b49743c0 | ||
|
|
506caa2c53 | ||
|
|
388a8c1fae | ||
|
|
42b208ff28 | ||
|
|
309f84b388 | ||
|
|
00608401aa | ||
|
|
229d4bbb2b | ||
| 845359b885 | |||
|
|
d4a46420c9 | ||
|
|
f704244b84 | ||
|
|
782110604b | ||
|
|
19bf032a2e | ||
|
|
b7464c9a11 | ||
|
|
524aaf85af | ||
|
|
a9e7479326 | ||
|
|
2e80cc606e | ||
|
|
d099138487 | ||
|
|
2278d97b7e | ||
|
|
74255ddc82 | ||
|
|
b466246c1b | ||
|
|
b3eb39a28b | ||
|
|
253e6f05e0 | ||
|
|
042a1b47c2 | ||
|
|
7a20534e7c | ||
|
|
ee2cbc92ef | ||
|
|
373f04a034 | ||
|
|
43d517dcfc | ||
|
|
8891d48af2 | ||
|
|
0b72c0fb53 | ||
|
|
a41e5b5b2d | ||
|
|
00c62178e1 | ||
|
|
bbe7d73de2 | ||
|
|
0934b294c2 | ||
|
|
b28d8f2f4a | ||
|
|
ec4ec44603 | ||
|
|
ee09706811 | ||
|
|
c06d1d6afb | ||
|
|
f906e7086c | ||
|
|
caf900b02d | ||
|
|
e80e3fccc0 | ||
|
|
e8056553fd | ||
|
|
ea4d2d7c0c | ||
|
|
98c188a5da | ||
|
|
0c3dcb0052 | ||
|
|
e017d66023 | ||
|
|
ba0b38b4f1 | ||
|
|
5b4cdd366e | ||
|
|
7c0f8d8408 | ||
|
|
0a7fcae137 | ||
|
|
5346737e2b | ||
|
|
80f6669585 | ||
|
|
27054e6715 | ||
|
|
ea7694566d | ||
|
|
46e01aefed | ||
|
|
41e0bea162 | ||
|
|
86012e02b9 | ||
|
|
da19eb807b | ||
|
|
0d37473575 | ||
|
|
6a4bf676ff | ||
|
|
a135485339 | ||
|
|
3c420acd54 | ||
|
|
5ced1b97a6 | ||
|
|
1344beba56 | ||
|
|
c8c8bb4a47 | ||
|
|
6f725d12f5 | ||
|
|
9952ff98f2 | ||
|
|
4a6d96b90e | ||
|
|
2690332d13 | ||
|
|
31218fc205 | ||
|
|
cc01871407 | ||
|
|
e70ae7f6ce | ||
|
|
1830273a9d | ||
|
|
1a10e6fa09 | ||
|
|
df57c2bc05 | ||
|
|
990be09bd7 | ||
|
|
e275f67a5e | ||
|
|
ff3de1d100 | ||
|
|
a4e313dbad | ||
|
|
7de5510735 | ||
|
|
5e54275842 | ||
|
|
6ac88235a7 | ||
|
|
c599fdcb8c | ||
|
|
b0b15e474e | ||
|
|
839f862b7d | ||
|
|
2901a769d8 | ||
|
|
e74e7eecf4 | ||
|
|
bba577888b | ||
|
|
5784dbee94 | ||
|
|
5348220e60 | ||
|
|
cd0b95ef9a | ||
|
|
fc1cfe59ec | ||
|
|
7c312161bb | ||
|
|
480eb0817a | ||
|
|
1b94fa5c44 | ||
|
|
02464b7f89 | ||
|
|
68f461d0e1 | ||
|
|
cfb410dd4d | ||
|
|
b378fbf550 | ||
|
|
cb43bcdd10 | ||
|
|
31420574db | ||
|
|
07dee31847 | ||
|
|
4debd5ce09 | ||
|
|
1495c63e3d | ||
|
|
953d93179d | ||
|
|
1bc7fcc609 | ||
|
|
c911717a3b | ||
|
|
949911f6c8 | ||
|
|
f3a58a6515 | ||
|
|
ee4cd706ef | ||
|
|
e11b01951e | ||
|
|
3d0cc4ffed | ||
|
|
4585b20f80 | ||
|
|
c53b5878cf | ||
|
|
c13ae437f7 | ||
|
|
5780879629 | ||
|
|
2bcd5ef9bd | ||
|
|
63eb860e40 | ||
|
|
e80ac7de49 | ||
|
|
3331c24898 | ||
|
|
1c20d8f846 | ||
|
|
77a1460e3a | ||
|
|
21a1870fd7 | ||
|
|
3ebbdb3f6e | ||
|
|
535d0c5558 | ||
|
|
2d807aa606 | ||
|
|
93ee7b72d5 | ||
|
|
32ef1b389a | ||
|
|
0885518a68 | ||
|
|
944d3bd3e8 | ||
|
|
883c98dc0a |
@@ -2,7 +2,14 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
"Bash(git commit:*)",
|
||||
"mcp__plugin_context-mode_context-mode__batch_execute",
|
||||
"mcp__plugin_context-mode_context-mode__execute",
|
||||
"mcp__plugin_context7_context7__query-docs",
|
||||
"mcp__plugin_context-mode_context-mode__search",
|
||||
"Bash(git fetch *)",
|
||||
"PowerShell(cmdkey *)",
|
||||
"mcp__plugin_context7_context7__resolve-library-id"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
16
.gitattributes
vendored
Normal file
16
.gitattributes
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.sln text eol=crlf
|
||||
*.slnx text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.bat text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.zip binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
cd "$WORK/src"
|
||||
dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \
|
||||
-c Release -r win-x64 --self-contained true \
|
||||
/p:Version=$VERSION -o out/app
|
||||
/p:MinVerVersionOverride=$VERSION -o out/app
|
||||
|
||||
- name: Publish ClaudeDo.Worker (win-x64, self-contained)
|
||||
env:
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
cd "$WORK/src"
|
||||
dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \
|
||||
-c Release -r win-x64 --self-contained true \
|
||||
/p:Version=$VERSION -o out/worker
|
||||
/p:MinVerVersionOverride=$VERSION -o out/worker
|
||||
|
||||
- name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
|
||||
env:
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
# Target machines need .NET 8 Desktop Runtime (x64).
|
||||
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
|
||||
-c Release -r win-x64 --self-contained false \
|
||||
/p:Version=$VERSION /p:PublishSingleFile=true \
|
||||
/p:MinVerVersionOverride=$VERSION /p:PublishSingleFile=true \
|
||||
-o out/installer
|
||||
|
||||
- name: Package assets
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# Local dev worktrees (created by using-git-worktrees skill)
|
||||
.worktrees/
|
||||
|
||||
# .NET build output
|
||||
bin/
|
||||
obj/
|
||||
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -35,18 +35,23 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
|
||||
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
|
||||
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
|
||||
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
|
||||
- Task status flow: Manual | Queued -> Running -> Done | Failed
|
||||
- Task status flow: Idle | Queued -> Running -> Done | Failed | Cancelled
|
||||
- Worktree state flow: Active -> Merged | Discarded | Kept
|
||||
- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup
|
||||
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
|
||||
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
|
||||
- Small single-consumer helper types live in their consumer's file, not standalone files
|
||||
- Commit messages use conventional format: `{commitType}(slug): title`
|
||||
- Views use compiled bindings (`x:DataType`)
|
||||
- ViewModels use `[ObservableProperty]` and `[RelayCommand]` source generators
|
||||
|
||||
## Building & Testing
|
||||
|
||||
`dotnet build ClaudeDo.slnx` requires .NET 9; on .NET 8 build individual projects instead.
|
||||
|
||||
```bash
|
||||
dotnet build ClaudeDo.slnx
|
||||
dotnet test tests/ClaudeDo.Worker.Tests
|
||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj # pulls in Ui + Data
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
dotnet test tests/ClaudeDo.Worker.Tests # also: Data.Tests, Ui.Tests, Installer.Tests, Releases.Tests
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
<Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
|
||||
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
|
||||
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
|
||||
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj" />
|
||||
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
|
||||
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
|
||||
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
|
||||
<Project Path="tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
8
Directory.Build.props
Normal file
8
Directory.Build.props
Normal file
@@ -0,0 +1,8 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<MinVerTagPrefix>v</MinVerTagPrefix>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MinVer" Version="5.0.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
37
README.md
37
README.md
@@ -16,29 +16,29 @@ Two-process system communicating over SignalR:
|
||||
| **ClaudeDo.Worker** | ASP.NET Core hosted service, task queue, Claude CLI runner |
|
||||
|
||||
```
|
||||
┌──────────────┐ SignalR ┌──────────────┐
|
||||
│ ClaudeDo.App│◄──────────►│ClaudeDo.Worker│
|
||||
│ (Avalonia) │ 127.0.0.1 │ (ASP.NET) │
|
||||
│ │ :47821 │ │
|
||||
│ ┌──────────┐│ │ ┌──────────┐ │
|
||||
│ │ Ui ││ │ │ TaskQueue│ │
|
||||
│ │(ViewModels)│ │ │ Claude CLI│ │
|
||||
│ └──────────┘│ │ └──────────┘ │
|
||||
└──────┬───────┘ └──────┬───────┘
|
||||
│ │
|
||||
└───────────┬───────────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ ClaudeDo.Data│
|
||||
│ (SQLite) │
|
||||
└─────────────┘
|
||||
┌────────────────┐ SignalR ┌────────────────┐
|
||||
│ ClaudeDo.App │◄───────────►│ ClaudeDo.Worker │
|
||||
│ (Avalonia) │ 127.0.0.1 │ (ASP.NET Core) │
|
||||
│ │ :47821 │ │
|
||||
│ ┌────────────┐│ │ ┌────────────┐ │
|
||||
│ │ Ui ││ │ │ TaskQueue │ │
|
||||
│ │(ViewModels)││ │ │ Claude CLI │ │
|
||||
│ └────────────┘│ │ └────────────┘ │
|
||||
└───────┬────────┘ └───────┬────────┘
|
||||
│ │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
┌───────┴───────┐
|
||||
│ ClaudeDo.Data │
|
||||
│ (SQLite) │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- .NET 8.0
|
||||
- Avalonia 12.0.0 (Fluent theme)
|
||||
- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM
|
||||
- SQLite (WAL mode) via Entity Framework Core (EF Core + Migrations)
|
||||
- SignalR for real-time IPC between UI and Worker
|
||||
- CommunityToolkit.Mvvm for source-generated MVVM
|
||||
- Git worktrees for task isolation
|
||||
@@ -53,7 +53,8 @@ Two-process system communicating over SignalR:
|
||||
|
||||
```bash
|
||||
# Build
|
||||
dotnet build ClaudeDo.slnx
|
||||
dotnet build src/ClaudeDo.App
|
||||
dotnet build src/ClaudeDo.Worker
|
||||
|
||||
# Run tests
|
||||
dotnet test tests/ClaudeDo.Worker.Tests
|
||||
|
||||
98
docs/mailbox-proposal.md
Normal file
98
docs/mailbox-proposal.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Task Mailbox — Push Messages Into Running Sessions
|
||||
|
||||
**Status:** proposal
|
||||
**Context:** the user runs parallel Claude sessions (e.g. backend + frontend) and wants to push messages into a session while it's busy inside a subagent. A shared folder works for one-offs; this turns it into a first-class ClaudeDo feature so every future parallel-session project gets it for free.
|
||||
|
||||
## Problem
|
||||
|
||||
Claude CLI processes one turn at a time. While a subagent (or any long tool) runs, no new user input can be injected. The harness offers no mid-execution interrupt. The workable window is *between* tool calls — so we need a cheap "inbox check" the agent can poll at natural checkpoints, plus a UI affordance and a cross-session sender.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Data
|
||||
|
||||
New table `task_messages`:
|
||||
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | |
|
||||
| `task_id` | TEXT FK → tasks.id | recipient |
|
||||
| `sender` | TEXT | `'user'` \| `'task:<id>'` (for cross-session) |
|
||||
| `body` | TEXT | markdown |
|
||||
| `created_at` | TEXT | ISO |
|
||||
| `delivered_at` | TEXT NULL | set when inbox pulls it |
|
||||
|
||||
EF Core migration + repository. Async, CancellationToken, matches existing conventions.
|
||||
|
||||
### 2. Worker MCP tools (extend existing `mcp__claudedo__*` server)
|
||||
|
||||
- **`check_inbox(task_id)`** → returns undelivered messages for this task and marks them delivered. Idempotent. Empty array if nothing pending.
|
||||
- **`send_to_task(task_id, body)`** → inserts a row. Callable from *any* session — this is how the frontend session tells the backend session something.
|
||||
- **`inbox_status(task_id)`** → `{ pending: int }` for a cheap "is there anything?" poll.
|
||||
|
||||
All three run in-proc in the Worker, go through the existing repository layer.
|
||||
|
||||
### 3. SignalR additions on `WorkerHub`
|
||||
|
||||
Server methods (UI → Worker):
|
||||
- `SendTaskMessage(taskId, body)` — UI calls this; worker inserts the row and fires `TaskMessageQueued`.
|
||||
|
||||
Client events (Worker → UI):
|
||||
- `TaskMessageQueued(taskId, pendingCount)` — so the UI can show an unread badge.
|
||||
- `TaskMessageDelivered(taskId, pendingCount)` — when the agent pulls it, badge clears.
|
||||
|
||||
### 4. UI
|
||||
|
||||
On every `Running` task row + detail pane:
|
||||
- "Send to session" textarea + Enter to submit → `SendTaskMessage`.
|
||||
- Unread badge showing `pendingCount`.
|
||||
- Read-only message timeline (who sent what, when delivered).
|
||||
|
||||
### 5. Agent-side poll discipline
|
||||
|
||||
Two complementary mechanisms so it's robust whether or not the agent remembers:
|
||||
|
||||
**a) CLAUDE.md instruction** (seeded by worker into each worktree's `CLAUDE.md`):
|
||||
> After every subagent completes and before starting the next step, call `mcp__claudedo__check_inbox`. Treat returned messages as user input with priority over the current plan.
|
||||
|
||||
**b) PostToolUse hook on `Agent`** (written into the worktree's `.claude/settings.json` by the Worker when it creates the tree):
|
||||
- Runs `mcp__claudedo__inbox_status` via a tiny CLI shim the worker ships.
|
||||
- If `pending > 0`, the hook emits a system reminder: "Inbox has N pending messages — call `mcp__claudedo__check_inbox` now."
|
||||
- Keeps the burden off the agent's memory. Belt + suspenders.
|
||||
|
||||
### 6. Cross-session pattern
|
||||
|
||||
Backend session and frontend session are just two tasks with known IDs. Either can call `send_to_task(other_id, body)` via the MCP server. No shared folder needed — the DB is already the shared channel.
|
||||
|
||||
To make this ergonomic:
|
||||
- A "linked tasks" concept: tag two tasks as peers at creation time. The Worker exposes `send_to_peer(body)` as sugar around `send_to_task` so neither session needs to hardcode the other's UUID.
|
||||
|
||||
## Limits (honest)
|
||||
|
||||
- Messages arrive *between* tool calls, not mid-tool. A 20-minute subagent still blocks 20 minutes. Splitting work into shorter subagents is still the right discipline.
|
||||
- If the agent ignores the CLAUDE.md instruction, the hook catches it next tool call — but we can't force immediate consumption.
|
||||
- `-p` (print) mode with stdin prompt is one-shot and can't be extended. This design targets *interactive* sessions (Planning Sessions already use this mode). For queued `-p` runs, the mailbox is effectively a post-run instruction carrier.
|
||||
|
||||
## Why this is the repeatable "Grundgerüst"
|
||||
|
||||
Once this lands in ClaudeDo, the workflow becomes:
|
||||
1. Create two linked tasks (`backend`, `frontend`) with `working_dir` set.
|
||||
2. Start each — each gets its own worktree, its own Planning Session terminal, its own inbox with `check_inbox` + `send_to_peer`.
|
||||
3. Push messages from the UI or from the other session. No per-project scaffolding, no custom hooks, no shared folder.
|
||||
|
||||
Every future parallel-session project inherits the mailbox.
|
||||
|
||||
## Build order (suggested)
|
||||
|
||||
1. Migration + repo + model. Tests first.
|
||||
2. MCP tools (`check_inbox`, `send_to_task`, `inbox_status`) + unit tests.
|
||||
3. SignalR method + events + UI textarea/badge.
|
||||
4. Worker writes CLAUDE.md addendum + `.claude/settings.json` hook into each new worktree.
|
||||
5. Linked-tasks sugar (`send_to_peer`).
|
||||
6. Manual verification: queue a long subagent, send a message, confirm it's picked up at the next tool boundary.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should messages be deleted or soft-kept after delivery? Leaning soft-kept for the timeline UI.
|
||||
- Priority / interrupt semantics — do we want a "high priority" flag that the agent should surface immediately vs. batch?
|
||||
- Should `send_to_peer` also work when the peer is `Queued` (i.e. not yet running)? Probably yes — deliver on start.
|
||||
339
docs/open.md
339
docs/open.md
@@ -1,193 +1,284 @@
|
||||
# ClaudeDo — Offene Punkte
|
||||
|
||||
Stand: 2026-04-13 nach Slice F. Branch `main` @ `48e4aab`. Alle Tests grün (38/38), Build 0 Warnings.
|
||||
Stand: 2026-04-30. Neu erstellt nach Code-Audit gegen `plan.md`, `improvement-plan.md` und `mailbox-proposal.md`.
|
||||
|
||||
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.
|
||||
Die alte Version dieses Dokuments war auf 2026-04-13 ("nach Slice F") datiert und ignorierte die seither gelandeten Slices (Planning Sessions, Prime Claude, Self-Update, Externe MCP-Tools, editierbare Status/Tags, BlockedBy-Chains). Diese Version trennt sauber zwischen **erledigt**, **teilweise**, **offen** — und listet das, was inzwischen gebaut wurde, explizit als „shipped" auf, damit es nicht verloren geht.
|
||||
|
||||
Legende: ✅ DONE — 🟡 PARTIAL — ⬜ OPEN — ⛔ DROPPED
|
||||
|
||||
---
|
||||
|
||||
## 0. Was seit dem 2026-04-13 dazugekommen ist
|
||||
|
||||
Diese Slices gab es im alten Dokument noch nicht (oder nur als Platzhalter). Sie sind **fertig im Code**, brauchen aber jeweils noch ein paar Polish-Punkte (siehe Sektion 2/3).
|
||||
|
||||
| Slice | Worker-Anker | UI-Anker | Status |
|
||||
|---|---|---|---|
|
||||
| **Planning Sessions** (Plan B+C) | `Planning/PlanningSessionManager`, `PlanningChainCoordinator`, `PlanningMcpService` | `Views/Planning/PlanningDiffView`, `ConflictResolutionView`, `UnfinishedPlanningModalView` | ✅ Code, manuelle Verifikation siehe §1.1 |
|
||||
| **Prime Claude** (geplante Recurrence) | `Prime/PrimeScheduler`, `NextDueCalculator`, `PrimeRunner` | `ViewModels/Modals/PrimeClaudeTabViewModel`, `Views/Controls/ThemedDatePicker` | ✅ Code, manuelle Verifikation siehe §1.2 |
|
||||
| **Self-Update System** (Gitea Releases) | — | `ClaudeDo.Releases` (`ReleaseClient`, `SelfUpdater`, `ChecksumVerifier`, `VersionComparer`), `ClaudeDo.Installer` (Pages/Steps/Core) | ✅ Code, manuelle Verifikation siehe §1.3 |
|
||||
| **Externes MCP-Endpoint** (11 Tools für Drittsessions) | `External/ExternalMcpService` (`ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus`, `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`), `ExternalMcpAuthMiddleware` (X-ClaudeDo-Key) | — | ✅ Code, ohne Tests am Endpoint selbst |
|
||||
| **Editierbare Status & Tags** (entkoppelt vom `agent`-Tag) | `WorkerHub.SetTaskStatus`, `SetTaskTags`, `UpdateTaskAgentSettings`; Queue-Picker filtert nicht mehr nach `agent`-Tag | `DetailsIslandViewModel`, Status-/Tag-Kontextmenü in `TasksIslandView` | ✅ Code |
|
||||
| **BlockedBy-Chains** (sequenzielle Subtask-Ausführung) | `TaskStateService.BlockOn`/`UnblockAsync`, `QueuePicker` filter `BlockedByTaskId IS NULL`, `PlanningChainCoordinator.OnChildFinishedAsync` | Drittes Feld neben `Status` und `PlanningPhase` | ✅ Code, Migration `20260423154708_AddPlanningSupport` |
|
||||
| **Worker-State-Konsolidierung** | `TaskStateService` ist alleiniger Owner von `Status`/`PlanningPhase`/`BlockedByTaskId`-Writes; `OverrideSlotService` ausgelagert; `QueueWaker` + `QueuePicker` getrennt | — | ✅ Code |
|
||||
| **MarkdownView / Tabbed Settings / About-Modal / Prime-Status-Footer / Doppelklick-Edit** | — | `Views/MarkdownView`, `SettingsModalView` als `TabControl`, `AboutModalView`, transient Prime-Status in Footer, `DoubleTapped` an List/Task-Rows | ✅ Code |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
Der Großteil der Verification-Steps aus `plan.md` ist im Code abgedeckt — was fehlt ist die **manuelle Bestätigung mit explizit notiertem Pass-Kriterium**. Ohne falsifizierbare Observable produziert ein Manual-Run nur "sah ok aus".
|
||||
|
||||
| # | 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 |
|
||||
### 1.0 Plan-Verification 1–13
|
||||
|
||||
**Vorschlag:** Wir machen einmal Step 4 (Haiku-Happy-Path) gemeinsam — wenn das läuft, ist die ganze Pipeline lebendig.
|
||||
| # | Item | Status | Pass-Kriterium (was muss konkret zu sehen sein) |
|
||||
|---|------|---|---|
|
||||
| 1 | Schema-Init | ✅ | `~/.todo-app/todo.db` + `*-wal` + `*-shm` existieren; EF-Migrationsverlauf in `__EFMigrationsHistory` enthält alle 8 Migrationen; Worker-Log: „listening on …" |
|
||||
| 1a | SignalR-Endpoint | ✅ | `curl http://127.0.0.1:47821/hub` → HTTP 400 (kein Handshake) |
|
||||
| 1b | Hub-Roundtrip `Ping` | 🟡 | UI-Statusbar zeigt „Connected"; `WorkerClient.PingAsync()` liefert `"pong"` (UI-Test fehlt) |
|
||||
| 2 | `claude --version` Preflight | ✅ | `Worker/Lifecycle/ClaudeCliPreflight.cs` + Wiring in `Program.cs`. Kaputter `claude_bin` → `LogCritical(...) + Environment.Exit(1)`. Skip via `CLAUDEDO_SKIP_CLI_PREFLIGHT=1`. Tests: `tests/.../Lifecycle/ClaudeCliPreflightTests.cs` |
|
||||
| 3 | Smoke-Spawn (`claude -p` Prompt „ping") | ⬜ | `task_runs`-Row mit `session_id NOT NULL`, `result` non-empty, `output_tokens > 0` |
|
||||
| 4 | E2E Happy Path (Non-Worktree) | ⬜ | Liste „Test" anlegen → Task „Schreibe ein Haiku über Intralogistik" → `tasks.status='Done'`, `tasks.result IS NOT NULL`, Logfile unter `~/.todo-app/logs/<taskId>.ndjson`, UI-Row mit Done-Badge |
|
||||
| 5 | Worktree Happy Path | ⬜ | Liste mit `working_dir` auf temp-Repo, Task mit Codeänderung → `worktrees.state='active'`, `head_commit IS NOT NULL`, `diff_stat` non-empty, Branch `claudedo/<id>` auf Disk |
|
||||
| 6 | No-Changes-Run | ⬜ | Prompt der nichts ändert → `tasks.status='Done'` aber `worktrees.head_commit IS NULL`, `diff_stat IS NULL` |
|
||||
| 7 | Kein Git-Repo | ⬜ | `working_dir=C:\Temp` (kein Repo) → `tasks.status='Failed'`, **keine** `worktrees`-Row, Worker-Log enthält Git-Fehler |
|
||||
| 8 | Merge-UI | 🟡 | `MergeTask`-Hub-Methode + `MergeModalView` vorhanden, manueller Run nicht durchgespielt → `worktrees.state='merged'`, im Ziel-Repo `git log` zeigt Commit, `git worktree list` ohne Branch |
|
||||
| 9 | Override-Parallelität | 🟡 | `OverrideSlotService`-Tests grün; UI-E2E nicht durchgespielt → `WorkerHub.GetActive` ≥ 2 Einträge bei Run+RunNow |
|
||||
| 10 | Schedule | 🟡 | `QueuePicker`-Tests grün; UI-E2E nicht → `scheduled_for=now+2min` bleibt Queued, dann automatisch Running, `started_at >= scheduled_for` |
|
||||
| 11 | Worker-Offline-Erkennung | 🟡 | `WorkerClient.OnServerConnectionClosed` + Auto-Reconnect implementiert (`WithAutomaticReconnect`); visuell prüfen: nach `taskkill` der Worker-Exe wechselt Statusbar in ≤ 5s auf „Offline", `RunNow`-Buttons disabled |
|
||||
| 12 | Live-Stream | 🟡 | `ClaudeProcess` streamt NDJSON via `TaskMessage`-Event, UI hat `LiveTail`; visuell prüfen: während Run laufen ndjson-Zeilen ein |
|
||||
| 13 | Wake-up (`WakeQueue` nach Anlage) | 🟡 | `QueueWaker.Wake()` wird bei Enqueue aufgerufen; visuell prüfen: Task wechselt in ≤ 1s auf Running (statt nach `queue_backstop_interval_ms`=30s) |
|
||||
|
||||
**Empfohlener Sprint:** Steps 3–7 in einem Rutsch durchspielen (alles non-UI), parallel daneben 8–13 visuell beim normalen App-Lauf abhaken.
|
||||
|
||||
### 1.1 Planning Sessions — Manual Verification (unverändert relevant)
|
||||
|
||||
Bedingt durch Slice "Planning B/C". Ablauf identisch zur alten open.md:
|
||||
|
||||
1. Manual-Task mit Title + TODO-Description anlegen.
|
||||
2. Rechtsklick → **Open planning Session** → Windows Terminal mit Claude CLI öffnet.
|
||||
3. In CLI: zwei Children via `mcp__claudedo__create_child_task` anlegen.
|
||||
4. UI: Drafts erscheinen eingerückt, italic, mit `DRAFT`-Badge; Parent zeigt `PLANNING`-Badge.
|
||||
5. Chevron klappt ein/aus.
|
||||
6. CLI `finalize` → Children werden Queued (erste) bzw. Queued+BlockedBy (Rest); Parent flippt von `Active` auf `Finalized` (`PLANNED`-Badge); erste Child startet automatisch.
|
||||
7. Neuer Planning-Task, Terminal ohne Finalize schließen → Rechtsklick öffnet Resume/Finalize-now/Discard-Modal.
|
||||
8. Delete-Versuch auf Parent mit Children → freundlicher Fehlerdialog, kein Delete.
|
||||
|
||||
**Bekannte Follow-ups (non-blocking):**
|
||||
- ✅ `Border.badge.planned` (blau) wird jetzt bei `Finalized` angewendet — `TaskRowView` nutzt `Classes.planning`/`Classes.planned` gebunden an `IsPlanActive`/`IsPlanFinalized`; der Child-„PLANNED"-Badge nutzt direkt `planned`.
|
||||
- ✅ Tote `Instance`-Statics auf `BoolToItalicConverter` und `BoolToDraftOpacityConverter` entfernt (Registrierung läuft über das Resource-Dictionary in `App.axaml`).
|
||||
- ✅ `Ui.Tests` IWorkerClient-Fakes auf gemeinsame Basis `StubWorkerClient` rebased — kein Constructor-Drift mehr; die drei Fakes überschreiben nur ihre relevanten Member.
|
||||
|
||||
### 1.2 Prime Claude — Manual Verification
|
||||
|
||||
Slice "Prime" (Recurrence-Scheduler).
|
||||
|
||||
1. Settings → Prime-Claude-Tab → Schedule mit `at: 09:00`, `every: workday`, `task_template: "Daily Standup"` anlegen.
|
||||
2. Test mit verschobenem `IPrimeClock` (oder Schedule mit `at: now+1min`) → bei Trigger erscheint Toast/Footer-Notification „Prime fired", neuer Task entsteht in der Ziel-Liste.
|
||||
3. Worker-Restart innerhalb des Schedule-Fensters → Catch-up läuft genau einmal (kein Doppelfeuer).
|
||||
4. Schedule editieren → `next_due_at` wird neu berechnet; UI-Anzeige aktualisiert.
|
||||
5. Schedule löschen → keine weiteren Trigger, keine ghost-Tasks.
|
||||
|
||||
### 1.3 Self-Update — Manual Verification (aus alter open.md, weiterhin gültig)
|
||||
|
||||
Voraussetzung: funktionierendes Gitea-Release unter `git.kuns.dev/releases/ClaudeDo` mit drei Assets — `ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, `checksums.txt`.
|
||||
|
||||
1. Baseline-Version (z.B. `0.2.x`) normal installieren.
|
||||
2. Neues Release `v0.3.0` mit frischem Installer + App-Zip + Checksums veröffentlichen.
|
||||
3. App starten → Banner erscheint: `Update available: v0.2.x → v0.3.0`.
|
||||
4. **Update now** klicken → App schließt, Installer öffnet im Update-Mode, läuft, restartet Worker.
|
||||
5. App neu starten → Banner weg; `Help → Check for updates` zeigt kurz „You're up to date (v0.3.0)".
|
||||
6. `v0.2.x`-Installer manuell starten → bietet Self-Update auf v0.3.0 an. **Update** → laufende Exe wird ersetzt, Wizard öffnet auf neuer Version.
|
||||
7. Schritt 6 mit **Continue anyway** → Wizard öffnet ohne Self-Update.
|
||||
8. Schritt 6 mit **Cancel** → Installer beendet ohne Aktion.
|
||||
9. Network-Kill in App und Installer beim Start → silent fallback (kein Error, kein Banner).
|
||||
|
||||
---
|
||||
|
||||
## 2. UI-Polish (kritisch für Benutzbarkeit)
|
||||
## 2. UI-Polish
|
||||
|
||||
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`
|
||||
### 2.1 Folder-Picker für `Working Directory` ⬜
|
||||
- **Datei:** `Views/ListSettingsModalView.axaml` + zugehöriges VM
|
||||
- **Aktuell:** plain `TextBox` — Pfad muss getippt werden.
|
||||
- **Soll:** Button "…" daneben → öffnet `IStorageProvider.OpenFolderPickerAsync`, schreibt Pfad ins Feld.
|
||||
- **Aufwand:** klein, ~30 Zeilen.
|
||||
- **Soll:** Button „…" daneben → öffnet `IStorageProvider.OpenFolderPickerAsync`, schreibt Pfad ins Feld.
|
||||
- **Aufwand:** klein.
|
||||
|
||||
### 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.2 Delete-Confirmation ⬜
|
||||
- **Aktuell:** Listen/Tasks-Delete läuft direkt ohne Rückfrage. Datenverlust-Risiko.
|
||||
- **Soll:** generischer `ConfirmDialog` (1× bauen, mehrfach nutzen), Mini-Dialog „Wirklich löschen?".
|
||||
- **Aufwand:** klein.
|
||||
|
||||
### 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.3 Markdown-Rendering Result + Description ✅
|
||||
- `Views/MarkdownView.axaml` + Detail-Pane verwenden Markdown.Avalonia.
|
||||
|
||||
### 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.4 Live-Log Auto-Scroll ⬜
|
||||
- **Datei:** `Views/DetailsIslandView.axaml(.cs)` (Live-Tail-Section)
|
||||
- **Aktuell:** ndjson-Zeilen werden angehängt, Scrollposition bleibt stehen.
|
||||
- **Soll:** Sticky-Bottom-Pattern — bei jeder neuen Zeile `ScrollToEnd()`, solange User nicht manuell hochgescrollt hat. 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.5 Diff-Viewer 🟡
|
||||
- `DiffModalView.axaml` + `PlanningDiffView` existieren; integriert für Planning-Merges.
|
||||
- **Offen:** Task-Level-Diff (Worktree vs. main) noch nicht im Modal-Flow geprüft. Verwenden statt `Process.Start("cmd /k git diff …")`.
|
||||
|
||||
### 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.6 Status-Bar Active-Tasks Live-Update ⬜
|
||||
- **Datei:** `ViewModels/StatusBarViewModel`
|
||||
- **Risiko:** `RunNowCommand.NotifyCanExecuteChanged` triggert nicht pro Item bei Connection-Change.
|
||||
- **Soll:** `WeakReferenceMessenger`-Connection-Change-Message; alle `TaskRowViewModel` lauschen.
|
||||
- **Aufwand:** klein, 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.
|
||||
### 2.7 Settings-Dialog ✅
|
||||
- `SettingsModalView` als `TabControl`, Tabs: General, Prime Claude, etc. Persistiert in `~/.todo-app/ui.config.json` und `worker.config.json`.
|
||||
|
||||
### 2.8 (NEU) Planning-Phase Badge-Farbe für `Finalized` ✅
|
||||
`Finalized` zeigt jetzt den blauen `planned`-Badge (Class-Binding in `TaskRowView`).
|
||||
|
||||
### 2.9 (NEU) Tote Converter-Statics entfernen ✅
|
||||
`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance` entfernt.
|
||||
|
||||
---
|
||||
|
||||
## 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.1 CLI-Preflight beim Worker-Start ✅
|
||||
- `src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs` + Wiring in `Program.cs`. Tests: `tests/.../Lifecycle/ClaudeCliPreflightTests.cs`. Skippable via `CLAUDEDO_SKIP_CLI_PREFLIGHT=1`.
|
||||
|
||||
### 3.2 Worktree-Cleanup beim Anlege-Failed
|
||||
### 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.
|
||||
- **Soll:** try/finally — bei Fehler zwischen `git worktree add` und DB-Insert `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.
|
||||
### 3.3 Logging über file-Sink ⬜
|
||||
- ILogger ist überall verdrahtet, aber kein File-Sink konfiguriert.
|
||||
- **Soll:** Serilog oder `Karambolage.Extensions.Logging.File` — für Service-Modus zwingend, console-only ist im SCM-Fenster verloren.
|
||||
- **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.
|
||||
### 3.4 Tag-Negation / Exclusion ⬜
|
||||
- Tags sind weiterhin rein additiv (`list_tags ∪ task_tags`). Nach Slice „Editierbare Tags" weniger dringend, aber nicht gelöst.
|
||||
- **Soll:** entweder neue Tabelle `task_tag_exclusions` oder Prefix `!tag` im task_tags-Eintrag.
|
||||
- **Aufwand:** mittel — Schema + Repo + Tests + UI.
|
||||
|
||||
---
|
||||
|
||||
## 4. Service-Deployment (Plan-Sektion „Worker als Windows-Service")
|
||||
## 4. Service-Deployment
|
||||
|
||||
### 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.1 Worker-Autostart als Per-User-Task ✅ (ersetzt Windows-Service)
|
||||
- Der Worker läuft **nicht mehr als Windows-Service** (LocalSystem konnte die Claude-CLI-Auth des Users nicht sehen). Stattdessen: per-user **Logon-Scheduled-Task** „ClaudeDoWorker" (`schtasks /Create /XML`), läuft als angemeldeter User, versteckt, mit Restart-on-Failure.
|
||||
- Worker ist `WinExe` (kein Konsolenfenster) + Serilog-File-Sink (`~/.todo-app/logs/worker-*.log`) + Single-Instance-Mutex.
|
||||
- Installer migriert beim Update den alten Service automatisch weg (`sc stop`/`delete`) und registriert die Task; Uninstall entfernt Task + Worker-Prozess. App startet/neustartet den Worker als Prozess und sorgt beim Start dafür, dass er läuft.
|
||||
- Implementiert 2026-05-29, getestet (Build + Unit-Tests grün), **manuelle E2E-Verifikation am Gerät ausstehend** (Update von 1.0.2-alpha → Task, Logoff/Logon-Autostart, Uninstall).
|
||||
|
||||
### 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.2 Pfad-Auflösung absolut ✅
|
||||
- `WorkerConfig.Load` expandiert `~`/`%USERPROFILE%` für alle Pfad-Felder.
|
||||
|
||||
### 4.3 Install-Skripte / Doku
|
||||
- **Datei:** *neu* — `docs/install-service.md` oder `scripts/install-service.cmd`
|
||||
### 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".
|
||||
### 4.4 Installer-Projekt ✅
|
||||
- `ClaudeDo.Installer` (WPF) + `ClaudeDo.Releases` mit Pages/Steps/Core/Theme — Self-Update funktioniert (siehe §1.3).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
### 5.1 CI-Pipeline (Gitea Actions) ⬜
|
||||
- **Datei (neu):** `.gitea/workflows/ci.yml`
|
||||
- **Inhalt:** `dotnet restore` → `dotnet build` (csproj-weise wegen `.slnx`-Bug auf .NET 8) → `dotnet test`. Auf Push + PR.
|
||||
- **Achtung:** Pipeline darf NICHT die `.slnx` als Build-Target nehmen — explizite csproj-Liste in einem checked-in Build-Skript.
|
||||
- **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.2 SignalR-Hub-Tests ✅
|
||||
- `tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs`, `AgentSettingsHubTests.cs` testen Hub-Methoden via Fakes (kein realer SignalR-Roundtrip, aber alle Code-Pfade abgedeckt).
|
||||
- **Optional verbleibt:** echter Roundtrip-Test mit `WebApplicationFactory<Program>` + `HubConnectionBuilder` für End-to-End-Validierung der SignalR-Pipeline. Niedriger Mehrwert solange Fakes alle Methoden treffen.
|
||||
|
||||
### 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.
|
||||
### 5.3 Smoke-Test gegen echten `claude` ⬜
|
||||
- **Datei (neu):** `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs`
|
||||
- **Soll:** Real-CLI-Test mit `[Fact(Skip="...")]` ausgegraut, nur lokal aktiviert wenn `CLAUDE_AUTHENTICATED=1` Env-Var gesetzt ist.
|
||||
- **Aufwand:** klein.
|
||||
|
||||
### 5.4 (NEU) ExternalMcpService-Tests ⬜
|
||||
- `External/ExternalMcpService` hat 11 Tools, aber nur partielle Coverage in `tests/.../External/ExternalMcpServiceTests.cs`. Für jedes Tool mindestens einen Happy-Path + einen Error-Pfad ergänzen.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dokumentation
|
||||
|
||||
### 6.1 README.md
|
||||
- Komplett fehlt. Mind. 1× kurz: was ist es, wie starten (Worker + UI), wo Config.
|
||||
- **Aufwand:** klein.
|
||||
### 6.1 README.md ⬜
|
||||
- Komplett fehlt. Mind. 1× kurz: was ist es, wie starten (Worker + UI), wo Config, wie Self-Update.
|
||||
|
||||
### 6.2 `docs/architecture.md`
|
||||
- In `plan.md` schon teilweise enthalten — kann entweder konsolidiert oder explizit ausgegliedert werden.
|
||||
### 6.2 docs/architecture.md 🟡
|
||||
- In `plan.md` enthalten — entweder konsolidieren oder explizit ausgliedern. CLAUDE.md-Dateien pro Projekt sind aktuell de-facto-Architecture-Doc.
|
||||
|
||||
### 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".
|
||||
### 6.3 ADRs ⬜
|
||||
- Vorschläge: „SignalR vs. SQLite-Polling für IPC", „Worktree pro Task", „TaskStateService als alleiniger State-Owner", „BlockedByTaskId statt Status='Waiting'", „External MCP als zweite WebApplication".
|
||||
- **Aufwand:** klein, hilfreich für später.
|
||||
|
||||
### 6.4 (NEU) Mailbox-Proposal ⬜
|
||||
- `docs/mailbox-proposal.md` ist als Vorschlag vorhanden, nicht implementiert. Entscheidung: bauen, droppen oder parken? Wenn droppen → Datei entfernen, sonst klare Roadmap.
|
||||
|
||||
---
|
||||
|
||||
## 7. Bekannte Code-Schulden / Smells
|
||||
|
||||
| Stelle | Issue |
|
||||
|---|---|
|
||||
| `WorkerHub.GetActive` returnt `IReadOnlyList<object>` 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. |
|
||||
| Stelle | Issue | Status |
|
||||
|---|---|---|
|
||||
| `WorkerHub.GetActive` returnt `IReadOnlyList<object>` mit anonymen Typen | Sollte expliziter `ActiveTaskDto` sein, den Worker UND UI teilen | ✅ (gibt bereits `IReadOnlyList<ActiveTaskDto>` zurück) |
|
||||
| `TaskRunner` führt eine `if (list.WorkingDir != null)`-Verzweigung mitten in der Methode | Strategy-Pattern wenn die Methode wächst, aktuell noch klein genug | ⬜ |
|
||||
| `App.Services` als public static `ServiceProvider` | Service-Locator-Antipattern, toleriert weil nur in `App.OnFrameworkInitializationCompleted` | ⬜ |
|
||||
| Embedded `schema.sql` ohne Versionierung | Durch EF-Core-Migrationen ersetzt | ✅ |
|
||||
| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer | ✅ (`.gitattributes` angelegt) |
|
||||
| Tote Converter-Instances (`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance`) | Per Resource-Dictionary registriert, Statics ungenutzt | ✅ (entfernt) |
|
||||
| 1 unausgeführter `// TODO` in `DetailsIslandViewModel` (`SendPromptAsync` ohne Hub-Methode) | Entweder Hub-Methode bauen oder TODO entfernen | ✅ (im Main-Code nicht mehr vorhanden) |
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Reihenfolge für die nächste Session
|
||||
## 8. Improvement Plan (improvement-plan.md, Stand 2026-04-13)
|
||||
|
||||
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.
|
||||
| ID | Item | Status | Bemerkung |
|
||||
|---|---|---|---|
|
||||
| IP-1 | UI ↔ Worker Auto-Reconnect | ✅ | `WorkerClient` mit `WithAutomaticReconnect()` + Reconnect-Handler |
|
||||
| IP-2 | Listen-Modus „Notes" (non-autonomous) | ⬜ | Nach Slice „editierbare Status/Tags" weniger dringend (man kann jetzt einen Task ohne `agent`-Tag idle lassen), aber `lists.kind` als sauberer Mode-Switch fehlt. |
|
||||
| IP-3 | Doppelklick öffnet Edit-Dialog | ✅ | `DoubleTapped`-Handler in `ListsIslandView`, `TasksIslandView` |
|
||||
| IP-4 | Tag Multi-Select Control | ⬜ | Tags sind via Picker im Detail-Pane editierbar, aber kein dediziertes Multi-Select-Control mit Auto-Vervollständigung in Editor-Dialogen. |
|
||||
| IP-5 | Rechtsklick-Kontextmenü | ✅ | Listen + Tasks haben Context-Menüs (Edit, Delete, Run Now, Show Diff, Merge, Cancel, Status, Tags) |
|
||||
| IP-6 | Schema-Migration-Mechanismus | ✅ | EF-Core-Migrations + `__EFMigrationsHistory` |
|
||||
| IP-7 | Status-Bar Reconnect-States | ✅ | `connected`/`connecting`/`reconnecting`/`offline` farbcodiert |
|
||||
| IP-8 | Tag-Repository `GetAllKnownTagsAsync` | ✅ | `TagRepository.GetAllAsync` + `WorkerClient.GetAllTagsAsync` |
|
||||
|
||||
Punkte 1–3 sind ein realistischer Block für eine Session.
|
||||
---
|
||||
|
||||
## 9. Empfohlene Reihenfolge für die nächsten Sessions
|
||||
|
||||
**Block 1 — Verification durchspielen** (kein neuer Code, nur Beweis):
|
||||
1. §1.0 Steps 3–7 manuell (Smoke + E2E + Worktree + No-Changes + Kein-Repo) — ist die Pipeline wirklich lebendig?
|
||||
2. §1.1 Planning-Walkthrough — nach den uncommitted Coordinator-Änderungen einmal durchspielen.
|
||||
3. §1.2 Prime-Walkthrough — Schedule-Trigger einmal beobachten.
|
||||
|
||||
**Block 2 — Niedrig hängende UI-Polish** (eine Session):
|
||||
4. §2.1 Folder-Picker
|
||||
5. §2.2 Delete-Confirmation
|
||||
6. §2.4 Live-Log Auto-Scroll
|
||||
7. §2.6 Status-Bar Live-Update
|
||||
8. §2.8 Planning-Badge-Farbe + §2.9 tote Converter weg
|
||||
|
||||
**Block 3 — Robustheit & Service-Deployment**:
|
||||
9. §3.2 Worktree-Cleanup
|
||||
10. §3.3 File-Sink-Logging
|
||||
11. §4.3 Install-Skripte/Doku
|
||||
|
||||
**Block 4 — Sicherheitsnetz**:
|
||||
12. §5.1 Gitea-Actions CI-Pipeline (csproj-weise)
|
||||
13. §5.3 Smoke-Test gegen echten claude
|
||||
14. §5.4 ExternalMcpService-Tests vervollständigen
|
||||
|
||||
**Block 5 — Dokumentation & Aufräumen**:
|
||||
15. §6.1 README
|
||||
16. §6.3 ADRs (mind. die fünf wichtigsten)
|
||||
17. §6.4 Mailbox-Proposal: bauen/droppen entscheiden
|
||||
18. §7 Smells: `ActiveTaskDto`, `.gitattributes`, TODO-Comment
|
||||
|
||||
**Block 6 — Optional / wenn Bedarf konkret wird**:
|
||||
19. §3.4 Tag-Negation
|
||||
20. §IP-2 Notes-Modus
|
||||
21. §IP-4 Tag Multi-Select Control
|
||||
|
||||
@@ -49,7 +49,9 @@ Schema in 3NF. Keine Mehrwert-Felder (z.B. JSON-Arrays), keine transitiven Abhä
|
||||
- `list_id` TEXT NOT NULL REFERENCES `lists(id)` ON DELETE CASCADE
|
||||
- `title` TEXT NOT NULL
|
||||
- `description` TEXT NULL
|
||||
- `status` TEXT NOT NULL — `manual` | `queued` | `running` | `done` | `failed` (`running` bleibt persistiert für Crash-Recovery: stale `running`-Tasks werden beim Worker-Start auf `failed` gesetzt)
|
||||
- `status` TEXT NOT NULL — Lifecycle-only: `idle` | `queued` | `running` | `done` | `failed` | `cancelled` (`running` bleibt persistiert für Crash-Recovery: stale `running`-Tasks werden beim Worker-Start auf `failed` gesetzt). Planungs-Hierarchie und Chain-Blocking laufen über zwei separate Felder.
|
||||
- `planning_phase` TEXT NOT NULL DEFAULT `'none'` — Parent-only Marker: `none` | `active` (Planung läuft) | `finalized` (Plan committed, Children existieren). Ein Parent kann `status='idle'` sein und gleichzeitig `planning_phase='finalized'` (für Re-Runs).
|
||||
- `blocked_by_task_id` TEXT NULL REFERENCES `tasks(id)` ON DELETE SET NULL — Vorgänger in einem sequenziellen Subtask-Chain. Ein `queued`-Row mit `blocked_by_task_id IS NOT NULL` wird vom Picker übersprungen.
|
||||
- `scheduled_for` TIMESTAMP NULL — "nicht vor"
|
||||
- `result` TEXT NULL (Markdown)
|
||||
- `log_path` TEXT NULL — Pfad zur ndjson-Log-Datei
|
||||
|
||||
201
docs/prompts-inventory.md
Normal file
201
docs/prompts-inventory.md
Normal file
@@ -0,0 +1,201 @@
|
||||
|
||||
|
||||
|
||||
# ClaudeDo — Prompt & CLI Inventory
|
||||
|
||||
Snapshot of every string ClaudeDo sends to Claude CLI, plus the CLI-flag surface that shapes each run. Intended as a working doc for tomorrow's prompt-tuning pass.
|
||||
|
||||
Date: 2026-04-24
|
||||
|
||||
---
|
||||
|
||||
## 1. Task-execution prompts (agent-tagged tasks → Claude CLI)
|
||||
|
||||
Used for every "agent" task that the queue picks up or that `RunNow` dispatches.
|
||||
Orchestration lives in `src/ClaudeDo.Worker/Runner/TaskRunner.cs` and `ClaudeArgsBuilder.cs`.
|
||||
|
||||
### 1.1 User prompt (stdin) — `TaskRunner.RunAsync` ~L101–L110
|
||||
|
||||
Plain text, no template around it:
|
||||
|
||||
```
|
||||
{task.Title}
|
||||
|
||||
{task.Description?.Trim()} ← only if non-empty
|
||||
|
||||
## Sub-Tasks ← only if subtasks exist
|
||||
- [ ] {subtask.Title} ← "[x]" if completed
|
||||
...
|
||||
```
|
||||
|
||||
Notes
|
||||
- Title is included verbatim — no leading `#` heading.
|
||||
- No role tags, no XML, no delimiters between title and description — just blank lines.
|
||||
- Sub-Tasks section uses markdown checkboxes. This is the only structural scaffolding.
|
||||
- No context about the project, working dir, or git state is added here.
|
||||
|
||||
### 1.2 Retry prompt (on failure, when a session ID exists) — `TaskRunner` ~L126
|
||||
|
||||
```
|
||||
The previous attempt failed with:
|
||||
|
||||
{result.ErrorMarkdown}
|
||||
|
||||
Try again and fix the issues.
|
||||
```
|
||||
|
||||
Fired once per task via `--resume <session_id>`; if the retry also fails, the task is marked Failed.
|
||||
|
||||
### 1.3 Follow-up prompt (multi-turn `ContinueAsync`) — `TaskRunner.ContinueAsync` L159
|
||||
|
||||
The UI/hub supplies `followUpPrompt` as-is; no wrapping. The session is resumed via `--resume`. So the effective "prompt template" is whatever the user types in the Continue textbox.
|
||||
|
||||
### 1.4 System prompt — merged in `TaskRunner` ~L413–L418
|
||||
|
||||
Built by `TaskRunner.MergeInstructions(global, list, task)` which concatenates three optional strings with `\n\n`:
|
||||
|
||||
1. `AppSettings.DefaultClaudeInstructions` (global, set in Settings modal, default `""`)
|
||||
2. `list_config.SystemPrompt` (per-list override)
|
||||
3. `task.SystemPrompt` (per-task override)
|
||||
|
||||
The merged string is passed as `--append-system-prompt <instructions>` to the CLI. Empty/whitespace → flag is omitted entirely.
|
||||
|
||||
**Currently the global `DefaultClaudeInstructions` ships as empty string** (see `AppSettingsEntity.cs` L9). Anything in the system prompt today is whatever the user typed into Settings / List-Settings / Task-Settings.
|
||||
|
||||
### 1.5 CLI args — `ClaudeArgsBuilder.Build` (`ClaudeArgsBuilder.cs`)
|
||||
|
||||
Always on:
|
||||
- `-p`
|
||||
- `--output-format stream-json`
|
||||
- `--verbose`
|
||||
- `--permission-mode {auto|acceptEdits|plan|default}` (legacy `bypassPermissions` → `auto`)
|
||||
|
||||
Conditional:
|
||||
- `--model {sonnet|opus|haiku|...}` — from `task.Model ?? list.Model ?? AppSettings.DefaultModel` (default `sonnet`)
|
||||
- `--max-turns {n}` — `AppSettings.DefaultMaxTurns` (default `100`)
|
||||
- `--append-system-prompt "{merged instructions}"` — see 1.4
|
||||
- `--agents '[{"file":"{path}"}]'` — from task or list override, points at an agent `.md`
|
||||
- `--resume {session_id}` — for retries and `ContinueAsync`
|
||||
|
||||
Unused but pre-declared:
|
||||
- `ResultSchema` — a `{summary, files_changed, commit_type}` JSON schema is serialized but **never attached** to args in `Build`. Dead code today; relevant if we turn on `--output-schema`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Planning-agent prompts (`/plan` / Planning session)
|
||||
|
||||
Used by the Planning feature, which spawns a Claude session inside a git worktree with MCP tools so the agent can create Subtasks under the parent.
|
||||
Source: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`.
|
||||
|
||||
### 2.1 System prompt — `BuildSystemPrompt()` L290–L308
|
||||
|
||||
```
|
||||
You are a planning assistant for ClaudeDo.
|
||||
Your role is to help break down a task into smaller, actionable subtasks.
|
||||
Your final goal WILL ALWAYS be the creation of Subtasks
|
||||
|
||||
ALWAYS invoke the `superpowers:brainstorming` skill via the Skill tool at the
|
||||
start of every planning session, and follow its process end-to-end. It guides
|
||||
you through clarifying questions, approach exploration, and design approval
|
||||
BEFORE any subtasks are created. Do not create child tasks until the user has
|
||||
approved a design.
|
||||
|
||||
NEVER Change files yourself.
|
||||
|
||||
ALWAYS Use the available MCP tools (mcp__claudedo__*) to create child tasks once the
|
||||
design is approved. When you are done planning, finalize the session.
|
||||
|
||||
Be concise and focused. Each subtask should be independently executable.
|
||||
```
|
||||
|
||||
Written to `{session-dir}/system-prompt.md` at session start and fed via `--append-system-prompt`.
|
||||
|
||||
Notes / known oddities
|
||||
- Trailing space on "NEVER Change files yourself. " and on the blank line above the ALWAYS/MCP block.
|
||||
- Mixes voice ("Your role is", "ALWAYS invoke") — could be tightened.
|
||||
- Implicitly relies on the `superpowers:brainstorming` skill being installed in the worktree's Claude config.
|
||||
- Does not name the MCP tools explicitly (the `mcp__claudedo__*` wildcard assumes the agent discovers them via tool listing).
|
||||
|
||||
### 2.2 Initial prompt — `BuildInitialPrompt(task)` L310–L323
|
||||
|
||||
```
|
||||
# Task: {task.Title}
|
||||
|
||||
{task.Description} ← only if non-empty
|
||||
|
||||
---
|
||||
|
||||
Please analyze this task and break it down into concrete subtasks.
|
||||
```
|
||||
|
||||
Written to `{session-dir}/initial-prompt.txt`; the Windows Terminal launcher pipes it to the Claude CLI on start.
|
||||
|
||||
### 2.3 Planning session CLI flags
|
||||
|
||||
`PlanningSessionManager` itself does not build CLI args — the `WindowsTerminalPlanningLauncher` does. Relevant facts:
|
||||
- Permission mode: **plan** (per recent commit `8e9f09a` "run planning agent in plan permission mode and enforce brainstorming skill").
|
||||
- Runs with an `.mcp.json` that points at our local MCP server (`http://127.0.0.1:{port}/mcp`) with a per-session bearer token.
|
||||
- `.claude/settings.local.json` sets `"enableAllProjectMcpServers": true` so the MCP tools auto-activate.
|
||||
|
||||
---
|
||||
|
||||
## 3. Commit-message template (not a prompt, but agent-visible)
|
||||
|
||||
Built by `CommitMessageBuilder.Build` (`CommitMessageBuilder.cs`). Format:
|
||||
|
||||
```
|
||||
{commitType}({listSlug}): {title ≤60 chars}
|
||||
|
||||
{description ≤400 chars} ← only if set
|
||||
|
||||
ClaudeDo-Task: {taskId}
|
||||
```
|
||||
|
||||
- `commitType` comes from `task.CommitType` (default `chore`, list default configurable).
|
||||
- Slug = lowercased list name with non-alphanumerics stripped, runs collapsed to `-`.
|
||||
- The agent sees the resulting commit in `git log` during retries and follow-ups, so phrasing here bleeds into model behavior on multi-turn work.
|
||||
|
||||
---
|
||||
|
||||
## 4. Where each prompt is edited (UI surface)
|
||||
|
||||
| Prompt slot | Edited in | Stored as |
|
||||
|-------------------------------------|--------------------------------------------|--------------------------------------------|
|
||||
| Global `DefaultClaudeInstructions` | Settings modal (`SettingsModalViewModel`) | `app_settings.DefaultClaudeInstructions` |
|
||||
| Per-list system prompt | List-Settings modal | `list_config.SystemPrompt` |
|
||||
| Per-task system prompt | Details island / task agent settings | `tasks.system_prompt` |
|
||||
| Per-task agent file | Details island | `tasks.agent_path` (absolute `.md` path) |
|
||||
| Default model / max turns / perms | Settings modal | `app_settings.*` |
|
||||
| Planning system prompt | **Hard-coded** in `PlanningSessionManager` | not editable from UI |
|
||||
| Planning initial prompt template | **Hard-coded** in `PlanningSessionManager` | not editable from UI |
|
||||
| Retry prompt | **Hard-coded** in `TaskRunner` | not editable |
|
||||
| Task prompt structure (title/desc) | **Hard-coded** in `TaskRunner` | not editable |
|
||||
|
||||
---
|
||||
|
||||
## 5. Things worth reviewing tomorrow
|
||||
|
||||
1. **Task-execution prompt has no frame at all.** Just title + description. Consider whether a thin wrapper (goal / constraints / done-criteria) improves agent focus without bloating small tasks.
|
||||
2. **Global DefaultClaudeInstructions is empty out of the box.** This is the cleanest place to put project-wide guardrails (commit format, branch etiquette, verify-before-done, no force push). Right now nothing is there.
|
||||
3. **Planning system prompt**:
|
||||
- Typo-level: trailing spaces, inconsistent capitalization ("ALWAYS"/"NEVER"/"Always").
|
||||
- "Your final goal WILL ALWAYS be the creation of Subtasks" conflicts slightly with "Do not create child tasks until the user has approved a design" — rewordable.
|
||||
- Does not state how many subtasks is reasonable, nor how granular.
|
||||
- Does not describe the MCP tool surface; the agent has to discover `mcp__claudedo__*` tools.
|
||||
4. **Retry prompt is minimal.** `"Try again and fix the issues."` — could be firmer about not repeating the same failure mode.
|
||||
5. **Sub-Tasks block** is dumped as plain checkboxes with no instruction ("please complete all open items", "do them in order", etc.). If the user relies on subtasks for ordering, that intent isn't conveyed.
|
||||
6. **ResultSchema is defined but unused.** Decide: drop it, or wire it up (`--output-schema`) and start asking for structured summaries.
|
||||
7. **Commit-message template** never tells the agent what `commit_type` to pick when it has flexibility — the value is hard-coded per task. Consider exposing as a prompt hint or inferring from diffs.
|
||||
|
||||
---
|
||||
|
||||
## 6. File pointers
|
||||
|
||||
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — user/retry/follow-up prompts, MergeInstructions
|
||||
- `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs` — CLI args + ResultSchema
|
||||
- `src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs` — commit template
|
||||
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — planning system + initial prompts
|
||||
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs` — planning CLI invocation
|
||||
- `src/ClaudeDo.Data/Models/AppSettingsEntity.cs` — global defaults
|
||||
- `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` — UI for global defaults
|
||||
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs` — UI for per-list overrides
|
||||
189
docs/superpowers/plans/2026-04-21-open-items-consolidation.md
Normal file
189
docs/superpowers/plans/2026-04-21-open-items-consolidation.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Open Items Consolidation — 2026-04-21
|
||||
|
||||
Consolidates everything still open from `docs/open.md`, `docs/improvement-plan.md`, and the 2026-04-16 subtask-tree spec. Today's plans/specs (stream-formatter, settings-modal, continue-and-reset) are explicitly out of scope — those are tracked separately.
|
||||
|
||||
Grouped by priority and sorted by UX impact vs. effort. Each item lists **Soll**, **Dateien**, **Aufwand**, **Risiko**.
|
||||
|
||||
---
|
||||
|
||||
## P1 — UX blockers and robustness
|
||||
|
||||
### 1. Auto-Reconnect (ex IP-1)
|
||||
**Soll:** `HubConnectionBuilder.WithAutomaticReconnect(...)` + event handlers for `Reconnecting`/`Reconnected`/`Closed`. Exponential backoff.
|
||||
**Dateien:** `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||
**Aufwand:** klein (~30 Zeilen)
|
||||
**Risiko:** klein
|
||||
|
||||
### 2. Reconnect-State in StatusBar (ex IP-7)
|
||||
**Soll:** States `connected | connecting | reconnecting | offline`, farb-codiert. Depends on #1.
|
||||
**Dateien:** `src/ClaudeDo.Ui/ViewModels/StatusBarViewModel.cs`, StatusBar view
|
||||
**Aufwand:** klein
|
||||
**Risiko:** klein
|
||||
|
||||
### 3. Folder-Picker für Working Directory (ex open.md 2.1)
|
||||
**Soll:** Button neben Pfad-TextBox → `IStorageProvider.OpenFolderPickerAsync`.
|
||||
**Dateien:** `src/ClaudeDo.Ui/Views/ListEditorView.axaml(.cs)`, `ViewModels/ListEditorViewModel.cs`
|
||||
**Aufwand:** klein (~30 Zeilen)
|
||||
**Risiko:** klein
|
||||
|
||||
### 4. Markdown-Rendering für Result/Description (ex open.md 2.3)
|
||||
**Soll:** `Markdown.Avalonia` Paket einbinden, `MarkdownScrollViewer` statt readonly `TextBox` in Details-Island.
|
||||
**Dateien:** `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`, `Views/Islands/DetailsIslandView.axaml`
|
||||
**Aufwand:** mittel (Theme-Integration kann zicken)
|
||||
**Risiko:** klein–mittel
|
||||
|
||||
### 5. Live-Log Auto-Scroll (ex open.md 2.4)
|
||||
**Soll:** Sticky-Bottom-Pattern: `ScrollToEnd()` auf neue Zeilen, außer User hat manuell hochgescrollt.
|
||||
**Dateien:** `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml(.cs)`
|
||||
**Aufwand:** klein
|
||||
**Risiko:** klein
|
||||
|
||||
### 6. CLI-Preflight beim Worker-Start (ex open.md 3.1)
|
||||
**Soll:** Startup-Check `claude --version` + Login-Status. Wenn fehlt → laut failen mit Hinweis, nicht still in Queue idlen.
|
||||
**Dateien:** `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs`, Worker startup (Program.cs / HostedService)
|
||||
**Aufwand:** klein
|
||||
**Risiko:** klein
|
||||
|
||||
---
|
||||
|
||||
## P2 — Daten & Features
|
||||
|
||||
### 7. Notes-Mode (ex IP-2)
|
||||
**Soll:** Neue Spalte `lists.kind` (`agent` | `notes`). Worker filtert auf `kind = 'agent'`. UI versteckt Run/Schedule/Worktree-Felder für `notes`.
|
||||
**Dateien:**
|
||||
- Migration + `TaskList` Entity + `IEntityTypeConfiguration`
|
||||
- `src/ClaudeDo.Worker/Queue/QueueService.cs` (Filter)
|
||||
- `ViewModels/ListEditorViewModel.cs`, `Views/Islands/*` (conditional visibility)
|
||||
**Aufwand:** mittel
|
||||
**Risiko:** mittel (Default für bestehende Listen = `agent`)
|
||||
|
||||
### 8. Subtask-Tree im TaskList-Island (ex 2026-04-16 spec)
|
||||
**Soll:** Indented Subtasks unter Parent-Task, Expand/Collapse, Chevron-Spalte, Count-Indikator. Batch-Query `GetCountsByTaskIdsAsync`.
|
||||
**Dateien:** `ViewModels/TaskItemViewModel.cs` (+ `Subtasks`, `IsExpanded`, `HasSubtasks`), `Views/Islands/TaskListView.axaml`, `Data/Repositories/SubtaskRepository.cs`
|
||||
**Aufwand:** mittel
|
||||
**Risiko:** klein–mittel (spec ist fertig)
|
||||
|
||||
### 9. Tag-Repository `GetAllKnownTagsAsync` (ex IP-8)
|
||||
**Soll:** Distinct-Query über alle Tags. Voraussetzung für #10.
|
||||
**Dateien:** neuer `Data/Repositories/TagRepository.cs` (oder in bestehendem Repo)
|
||||
**Aufwand:** klein
|
||||
**Risiko:** klein
|
||||
|
||||
### 10. Tag Multi-Select Control (ex IP-4)
|
||||
**Soll:** AutoCompleteBox / Chips statt Freitext. Datenquelle aus #9.
|
||||
**Dateien:** `Views/Islands/DetailsIslandView.axaml` (Tag-Sektion), ggf. neues `TagPickerControl`
|
||||
**Aufwand:** klein–mittel
|
||||
**Risiko:** klein
|
||||
|
||||
### 11. Worktree-Cleanup bei Anlegefehler (ex open.md 3.2)
|
||||
**Soll:** Wenn `git worktree add` teilweise anlegt dann failed → best-effort `git worktree remove --force` + DB-Row nicht persistieren.
|
||||
**Dateien:** `src/ClaudeDo.Data/Services/GitService.cs`, `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
|
||||
**Aufwand:** klein
|
||||
**Risiko:** klein
|
||||
|
||||
### 12. Tag-Negation / Exclusion (ex open.md 3.4)
|
||||
**Soll:** Queue respektiert `task_tag_exclusions` laut Plan. Aktuell nur `agent`-Include.
|
||||
**Dateien:** `src/ClaudeDo.Worker/Queue/QueueService.cs`
|
||||
**Aufwand:** klein
|
||||
**Risiko:** klein
|
||||
|
||||
---
|
||||
|
||||
## P3 — Tests, CI, Docs
|
||||
|
||||
### 13. Gitea-Actions CI Pipeline (ex open.md 5.1)
|
||||
**Soll:** `.gitea/workflows/ci.yml`: restore → build → test auf push/PR. Nur `release.yml` existiert bisher.
|
||||
**Dateien:** neu — `.gitea/workflows/ci.yml`
|
||||
**Aufwand:** klein
|
||||
**Risiko:** klein
|
||||
|
||||
### 14. SignalR Roundtrip-Test (ex open.md 5.2)
|
||||
**Soll:** `WebApplicationFactory` + `HubConnectionBuilder` testen `Ping`, `GetActive`, `RunNow`-Throw-Verhalten.
|
||||
**Dateien:** neu — `tests/ClaudeDo.Worker.Tests/Hub/WorkerHubTests.cs`
|
||||
**Aufwand:** mittel
|
||||
**Risiko:** klein
|
||||
|
||||
### 15. Claude-CLI Smoke-Test (ex open.md 5.3)
|
||||
**Soll:** `[Fact(Skip=...)]` Real-CLI-Test, aktiviert nur wenn `CLAUDE_AUTHENTICATED=1`.
|
||||
**Dateien:** neu — `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs`
|
||||
**Aufwand:** klein
|
||||
**Risiko:** klein
|
||||
|
||||
### 16. README ausbauen (ex open.md 6.1)
|
||||
**Soll:** Ist 107 Zeilen. Ergänzen: Screenshots, Quickstart (Worker + UI starten), Konfiguration, Troubleshooting.
|
||||
**Dateien:** `README.md`
|
||||
**Aufwand:** klein
|
||||
**Risiko:** keiner
|
||||
|
||||
### 17. `docs/architecture.md` herausziehen (ex open.md 6.2)
|
||||
**Soll:** Architektur-Sektion aus `plan.md` in eigenes Dokument.
|
||||
**Dateien:** neu — `docs/architecture.md`
|
||||
**Aufwand:** klein
|
||||
**Risiko:** keiner
|
||||
|
||||
### 18. ADRs für Kern-Entscheidungen (ex open.md 6.3)
|
||||
**Soll:** Kurze ADRs (1 Seite) für: SignalR vs. SQLite-Polling; Worktree pro Task; SignalR über Loopback ohne Auth; EF Core statt Dapper.
|
||||
**Dateien:** neu — `docs/adr/0001-*.md` … `0004-*.md`
|
||||
**Aufwand:** klein
|
||||
**Risiko:** keiner
|
||||
|
||||
### 19. Strukturiertes Logging (ex open.md 3.3)
|
||||
**Soll:** `Console.WriteLine` / manuelle Log-Zeilen durch `ILogger<T>` ersetzen. Log-Levels, Scope für TaskId.
|
||||
**Dateien:** `src/ClaudeDo.Worker/**` (query-able via `grep Console.Write`)
|
||||
**Aufwand:** mittel
|
||||
**Risiko:** klein
|
||||
|
||||
---
|
||||
|
||||
## P4 — Service-Deployment (später)
|
||||
|
||||
### 20. Windows-Service Hosting in Code (ex open.md 4.1)
|
||||
**Soll:** `.UseWindowsService()` + `Microsoft.Extensions.Hosting.WindowsServices` Paket.
|
||||
**Aufwand:** klein
|
||||
|
||||
### 21. Absolute Pfad-Auflösung (ex open.md 4.2)
|
||||
**Soll:** Config-Pfade immer absolut auflösen (Service läuft in `C:\Windows\System32`).
|
||||
**Aufwand:** klein
|
||||
|
||||
### 22. Install-Skripte / Doku (ex open.md 4.3)
|
||||
**Soll:** `sc create` / PowerShell-Skript, Doku in README.
|
||||
**Aufwand:** klein
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Reihenfolge
|
||||
|
||||
**Block 1 — Sofortige UX-Wins (1 Session):**
|
||||
1. → 2. (Auto-Reconnect + StatusBar) — zusammenhängend, klein
|
||||
3. Folder-Picker
|
||||
5. Log Auto-Scroll
|
||||
|
||||
**Block 2 — Content-Qualität (1 Session):**
|
||||
4. Markdown-Rendering
|
||||
6. CLI-Preflight
|
||||
|
||||
**Block 3 — Daten-Features (2 Sessions):**
|
||||
7. Notes-Mode (mit Migration)
|
||||
8. Subtask-Tree
|
||||
9. → 10. Tag-Repo + Multi-Select
|
||||
|
||||
**Block 4 — Worker-Robustheit:**
|
||||
11. Worktree-Cleanup
|
||||
12. Tag-Exclusion
|
||||
19. Strukturiertes Logging
|
||||
|
||||
**Block 5 — Tests + CI + Docs:**
|
||||
13. CI Pipeline
|
||||
14. → 15. Hub-Tests + Smoke-Test
|
||||
16. → 17. → 18. README + architecture.md + ADRs
|
||||
|
||||
**Block 6 — Service-Deployment (wenn gewünscht):**
|
||||
20. → 21. → 22.
|
||||
|
||||
---
|
||||
|
||||
## Nicht im Plan
|
||||
|
||||
- Alles aus heute (2026-04-21): Stream-Formatter-Rewrite, Settings-Modal, Continue-and-Reset — sind eigene Plans.
|
||||
- UI-Rewrite-Islands, UI-Polish-Design-Parity, UX-Redesign, Worker-CLI-Modernization, EF-Core-Migration, Installer-Download-Mode, Logic-Bug-Fixes — bereits gemerged (siehe git log).
|
||||
- IP-3 (Doppelklick) und IP-5 (Kontextmenü) — vermutlich im Zuge des UI-Rewrites erledigt; falls nicht, trivial nachzuziehen.
|
||||
1223
docs/superpowers/plans/2026-04-22-agent-settings-ui.md
Normal file
1223
docs/superpowers/plans/2026-04-22-agent-settings-ui.md
Normal file
File diff suppressed because it is too large
Load Diff
1955
docs/superpowers/plans/2026-04-22-worktree-merge.md
Normal file
1955
docs/superpowers/plans/2026-04-22-worktree-merge.md
Normal file
File diff suppressed because it is too large
Load Diff
852
docs/superpowers/plans/2026-04-23-default-agents.md
Normal file
852
docs/superpowers/plans/2026-04-23-default-agents.md
Normal file
@@ -0,0 +1,852 @@
|
||||
# Default Agents 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:** Ship ClaudeDo with 6 default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher) that seed into `~/.todo-app/agents/` on first launch, with a "Restore defaults" button in the settings modal.
|
||||
|
||||
**Architecture:** Bundled `.md` files in `src/ClaudeDo.Worker/DefaultAgents/` are copied to the Worker output folder. A new `DefaultAgentSeeder` service copies any missing file into the user's agents dir — run once at startup, and again on demand via a new `WorkerHub.RestoreDefaultAgents` method invoked by a button in `SettingsModalView`.
|
||||
|
||||
**Tech Stack:** .NET 8 / ASP.NET Core / SignalR / Avalonia 12 / CommunityToolkit.Mvvm / xUnit
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create:**
|
||||
- `src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md`
|
||||
- `src/ClaudeDo.Worker/DefaultAgents/test-writer.md`
|
||||
- `src/ClaudeDo.Worker/DefaultAgents/debugger.md`
|
||||
- `src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md`
|
||||
- `src/ClaudeDo.Worker/DefaultAgents/explorer.md`
|
||||
- `src/ClaudeDo.Worker/DefaultAgents/researcher.md`
|
||||
- `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
|
||||
- `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
|
||||
|
||||
**Modify:**
|
||||
- `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — add Content item group for `DefaultAgents\*.md`
|
||||
- `src/ClaudeDo.Worker/Program.cs` — register seeder, run `SeedMissingAsync()` once at startup
|
||||
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — inject `DefaultAgentSeeder`, add `RestoreDefaultAgents` method
|
||||
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — add `RestoreDefaultAgentsAsync` method + `SeedResultDto` record
|
||||
- `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` — add `RestoreDefaultAgentsCommand`
|
||||
- `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` — add button section
|
||||
- `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs` — add seeder integration test
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Bundle default agent markdown files
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md`
|
||||
- Create: `src/ClaudeDo.Worker/DefaultAgents/test-writer.md`
|
||||
- Create: `src/ClaudeDo.Worker/DefaultAgents/debugger.md`
|
||||
- Create: `src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md`
|
||||
- Create: `src/ClaudeDo.Worker/DefaultAgents/explorer.md`
|
||||
- Create: `src/ClaudeDo.Worker/DefaultAgents/researcher.md`
|
||||
|
||||
- [ ] **Step 1: Write `code-reviewer.md`**
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-reviewer
|
||||
description: Reviews code changes for bugs, logic errors, and convention violations. Flags only high-confidence issues.
|
||||
---
|
||||
|
||||
You are a code reviewer. Your job is to inspect the diff for real problems, not nitpicks.
|
||||
|
||||
Focus on:
|
||||
- Logic errors, off-by-one bugs, null/empty handling
|
||||
- Broken invariants, race conditions, resource leaks
|
||||
- Violations of the project's established conventions (read nearby code first)
|
||||
- Missing error handling at system boundaries (external input, IO, network)
|
||||
|
||||
Skip:
|
||||
- Style preferences the codebase doesn't enforce
|
||||
- Speculative "what if" concerns
|
||||
- Renaming for its own sake
|
||||
|
||||
Output: a short list of concrete issues with file:line references. If the diff is clean, say so in one sentence. Do not rewrite the code — call out the problem and let the implementer fix it.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write `test-writer.md`**
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: test-writer
|
||||
description: Generates unit and integration tests for existing or new code. Follows the project's test patterns and frameworks.
|
||||
---
|
||||
|
||||
You are a test-writer. Your job is to write focused, useful tests for code under review.
|
||||
|
||||
Process:
|
||||
1. Read the target code and identify the observable behavior.
|
||||
2. Read existing tests nearby to match the framework, fixtures, naming, and assertion style.
|
||||
3. Write tests covering the happy path, boundary conditions, and the specific failure modes that matter.
|
||||
|
||||
Rules:
|
||||
- One behavior per test. Clear Arrange/Act/Assert.
|
||||
- No tests for private implementation details — exercise public API.
|
||||
- No mocks where real objects are cheap (in-memory DBs, temp dirs).
|
||||
- Skip trivially-correct tests (getter returns what you set).
|
||||
|
||||
Output: the test file(s) ready to compile, matching the project's conventions. Include the command to run them.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write `debugger.md`**
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: debugger
|
||||
description: Systematic root-cause analysis for bugs, test failures, and unexpected behavior. Hypothesize, isolate, verify.
|
||||
---
|
||||
|
||||
You are a debugger. You do NOT guess at fixes — you find the root cause first.
|
||||
|
||||
Process:
|
||||
1. Reproduce. Get a minimal, deterministic repro. If you can't reproduce it, say so and stop.
|
||||
2. Isolate. Narrow the failing path (bisect, binary search, or tracing).
|
||||
3. Hypothesize. State a specific, falsifiable cause.
|
||||
4. Verify. Prove the hypothesis by observation (logs, debugger, targeted print) — not by "this seems likely".
|
||||
5. Fix at the root, not the symptom. If the only fix is a workaround, explain why.
|
||||
|
||||
Anti-patterns to avoid:
|
||||
- Making changes to "see if it works"
|
||||
- Adding try/catch to silence errors
|
||||
- Declaring the bug fixed without reproducing the fix
|
||||
|
||||
Output: repro steps, root cause, and the minimal fix. Include evidence (log excerpt, command output) that proves the cause.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write `security-reviewer.md`**
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: security-reviewer
|
||||
description: Audits code for OWASP-class security issues — auth, injection, input handling, secret exposure.
|
||||
---
|
||||
|
||||
You are a security reviewer. Focus on real, exploitable weaknesses — not theoretical hardening.
|
||||
|
||||
Check for:
|
||||
- Injection: SQL, command, path traversal, XSS, template injection
|
||||
- Auth: missing authorization, token handling, session fixation
|
||||
- Input validation at system boundaries (HTTP, files, IPC)
|
||||
- Secrets: hardcoded credentials, tokens in logs, leaked env vars
|
||||
- Unsafe deserialization, XXE, SSRF
|
||||
- Cryptography misuse (custom crypto, weak algorithms, fixed IVs)
|
||||
|
||||
Ignore:
|
||||
- Internal trust-boundary assumptions the project already documents
|
||||
- Defense-in-depth ideas with no concrete attack path
|
||||
|
||||
Output: a prioritized list — severity, file:line, the exploit path, the fix. If nothing is wrong, say so plainly.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Write `explorer.md`**
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: explorer
|
||||
description: Fast codebase navigation — find files, search for patterns, answer "where/how" questions. Terse output.
|
||||
---
|
||||
|
||||
You are an explorer. Your job is to find things in the codebase quickly and report back concisely.
|
||||
|
||||
Use:
|
||||
- Glob/Grep for searches
|
||||
- Read only for files you need to quote from
|
||||
|
||||
Do NOT:
|
||||
- Refactor, edit, or "improve" anything
|
||||
- Read files that aren't relevant to the question
|
||||
- Dump raw tool output — summarize
|
||||
|
||||
Output style:
|
||||
- Lead with the answer in one sentence.
|
||||
- Back it up with file:line references.
|
||||
- If you found nothing, say "no match" and what you searched for.
|
||||
|
||||
Keep responses short. The caller wants facts, not prose.
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Write `researcher.md`**
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: researcher
|
||||
description: General-purpose research and analysis for non-code tasks — summarize docs, investigate questions, draft prose.
|
||||
---
|
||||
|
||||
You are a researcher. You handle tasks that don't fit the code-review/test/debug shape.
|
||||
|
||||
Good fits:
|
||||
- Summarizing documents, specs, or long outputs
|
||||
- Investigating an open question (what does X do, how does Y work, what are the tradeoffs)
|
||||
- Drafting non-code text (release notes, emails, docs)
|
||||
- Analyzing structured data (logs, CSV, JSON) and reporting findings
|
||||
|
||||
Process:
|
||||
1. Restate the task in one sentence so you know what "done" looks like.
|
||||
2. Gather just enough information — stop when you can answer, not when you run out of sources.
|
||||
3. Distinguish facts ("the file says X") from inference ("so likely Y").
|
||||
4. Cite sources (file:line, URL, log excerpt) for every claim.
|
||||
|
||||
Output: direct answer first, supporting evidence second. Keep it short unless asked for depth.
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/DefaultAgents/
|
||||
git commit -m "feat(worker): add bundled default agent definitions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Wire bundled agents into build output
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
|
||||
- [ ] **Step 1: Add Content item group for DefaultAgents**
|
||||
|
||||
Edit `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`. After the existing `<ItemGroup>` blocks and before the final `<PropertyGroup>`, add:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<Content Include="DefaultAgents\*.md">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the worker and verify the files land in output**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: build succeeds.
|
||||
|
||||
Then verify output:
|
||||
|
||||
Run: `ls src/ClaudeDo.Worker/bin/Debug/net8.0/DefaultAgents/`
|
||||
Expected: all 6 `.md` files present.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
git commit -m "build(worker): ship DefaultAgents folder in build output"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Write DefaultAgentSeeder tests (failing)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
|
||||
|
||||
The service doesn't exist yet — these tests will fail to compile initially. That's fine; the next task implements it.
|
||||
|
||||
- [ ] **Step 1: Write the test file**
|
||||
|
||||
Create `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Worker.Services;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Services;
|
||||
|
||||
public sealed class DefaultAgentSeederTests : IDisposable
|
||||
{
|
||||
private readonly string _bundleDir;
|
||||
private readonly string _targetDir;
|
||||
|
||||
public DefaultAgentSeederTests()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"claudedo_seeder_{Guid.NewGuid():N}");
|
||||
_bundleDir = Path.Combine(root, "bundle");
|
||||
_targetDir = Path.Combine(root, "target");
|
||||
Directory.CreateDirectory(_bundleDir);
|
||||
Directory.CreateDirectory(_targetDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(Path.GetDirectoryName(_bundleDir)!, true); } catch { }
|
||||
}
|
||||
|
||||
private async Task WriteBundleAsync(string name, string content)
|
||||
{
|
||||
await File.WriteAllTextAsync(Path.Combine(_bundleDir, name), content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMissing_CopiesAllFiles_WhenTargetEmpty()
|
||||
{
|
||||
await WriteBundleAsync("a.md", "A");
|
||||
await WriteBundleAsync("b.md", "B");
|
||||
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||
|
||||
var result = await seeder.SeedMissingAsync();
|
||||
|
||||
Assert.Equal(2, result.Copied);
|
||||
Assert.Equal(0, result.Skipped);
|
||||
Assert.True(File.Exists(Path.Combine(_targetDir, "a.md")));
|
||||
Assert.True(File.Exists(Path.Combine(_targetDir, "b.md")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMissing_SkipsExistingFiles()
|
||||
{
|
||||
await WriteBundleAsync("a.md", "bundled");
|
||||
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "user-modified");
|
||||
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||
|
||||
var result = await seeder.SeedMissingAsync();
|
||||
|
||||
Assert.Equal(0, result.Copied);
|
||||
Assert.Equal(1, result.Skipped);
|
||||
var content = await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md"));
|
||||
Assert.Equal("user-modified", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMissing_MixedState_CopiesOnlyMissing()
|
||||
{
|
||||
await WriteBundleAsync("a.md", "A");
|
||||
await WriteBundleAsync("b.md", "B");
|
||||
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "existing");
|
||||
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||
|
||||
var result = await seeder.SeedMissingAsync();
|
||||
|
||||
Assert.Equal(1, result.Copied);
|
||||
Assert.Equal(1, result.Skipped);
|
||||
Assert.Equal("existing", await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md")));
|
||||
Assert.Equal("B", await File.ReadAllTextAsync(Path.Combine(_targetDir, "b.md")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMissing_ReturnsZero_WhenBundleDirMissing()
|
||||
{
|
||||
var missingBundle = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
|
||||
var seeder = new DefaultAgentSeeder(missingBundle, _targetDir);
|
||||
|
||||
var result = await seeder.SeedMissingAsync();
|
||||
|
||||
Assert.Equal(0, result.Copied);
|
||||
Assert.Equal(0, result.Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMissing_CreatesTargetDir_IfMissing()
|
||||
{
|
||||
await WriteBundleAsync("a.md", "A");
|
||||
var missingTarget = Path.Combine(_targetDir, "nested", "created");
|
||||
var seeder = new DefaultAgentSeeder(_bundleDir, missingTarget);
|
||||
|
||||
var result = await seeder.SeedMissingAsync();
|
||||
|
||||
Assert.Equal(1, result.Copied);
|
||||
Assert.True(File.Exists(Path.Combine(missingTarget, "a.md")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMissing_IgnoresNonMarkdownFiles()
|
||||
{
|
||||
await WriteBundleAsync("a.md", "A");
|
||||
await File.WriteAllTextAsync(Path.Combine(_bundleDir, "readme.txt"), "not an agent");
|
||||
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||
|
||||
var result = await seeder.SeedMissingAsync();
|
||||
|
||||
Assert.Equal(1, result.Copied);
|
||||
Assert.False(File.Exists(Path.Combine(_targetDir, "readme.txt")));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm they fail to compile**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DefaultAgentSeederTests"`
|
||||
Expected: compile error — `The type or namespace name 'DefaultAgentSeeder' could not be found`.
|
||||
|
||||
This confirms the tests target the not-yet-written service.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Implement DefaultAgentSeeder
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
|
||||
|
||||
- [ ] **Step 1: Write the service**
|
||||
|
||||
Create `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`:
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClaudeDo.Worker.Services;
|
||||
|
||||
public sealed record SeedResult(int Copied, int Skipped);
|
||||
|
||||
public sealed class DefaultAgentSeeder
|
||||
{
|
||||
private readonly string _bundleDir;
|
||||
private readonly string _targetDir;
|
||||
private readonly ILogger<DefaultAgentSeeder>? _logger;
|
||||
|
||||
public DefaultAgentSeeder(string bundleDir, string targetDir, ILogger<DefaultAgentSeeder>? logger = null)
|
||||
{
|
||||
_bundleDir = bundleDir;
|
||||
_targetDir = targetDir;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SeedResult> SeedMissingAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (!Directory.Exists(_bundleDir))
|
||||
{
|
||||
_logger?.LogWarning("DefaultAgents bundle dir not found: {Dir}", _bundleDir);
|
||||
return new SeedResult(0, 0);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_targetDir);
|
||||
|
||||
int copied = 0;
|
||||
int skipped = 0;
|
||||
|
||||
foreach (var src in Directory.EnumerateFiles(_bundleDir, "*.md"))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var fileName = Path.GetFileName(src);
|
||||
var dst = Path.Combine(_targetDir, fileName);
|
||||
|
||||
if (File.Exists(dst))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var input = File.OpenRead(src);
|
||||
using var output = File.Create(dst);
|
||||
await input.CopyToAsync(output, ct);
|
||||
copied++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to copy default agent {File}", fileName);
|
||||
}
|
||||
}
|
||||
|
||||
return new SeedResult(copied, skipped);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests and verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DefaultAgentSeederTests"`
|
||||
Expected: 6 tests pass.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs
|
||||
git commit -m "feat(worker): add DefaultAgentSeeder for first-launch agent seeding"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Wire seeder into Worker startup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Program.cs`
|
||||
|
||||
- [ ] **Step 1: Register seeder and run SeedMissingAsync at startup**
|
||||
|
||||
In `src/ClaudeDo.Worker/Program.cs`, replace the "Agent file management." block (currently lines 36–39):
|
||||
|
||||
Find:
|
||||
```csharp
|
||||
// Agent file management.
|
||||
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
||||
Directory.CreateDirectory(agentsDir);
|
||||
builder.Services.AddSingleton(new AgentFileService(agentsDir));
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```csharp
|
||||
// Agent file management.
|
||||
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
||||
Directory.CreateDirectory(agentsDir);
|
||||
builder.Services.AddSingleton(new AgentFileService(agentsDir));
|
||||
|
||||
var defaultAgentsBundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
|
||||
builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
|
||||
defaultAgentsBundleDir,
|
||||
agentsDir,
|
||||
sp.GetService<Microsoft.Extensions.Logging.ILogger<DefaultAgentSeeder>>()));
|
||||
```
|
||||
|
||||
Then, after `var app = builder.Build();` and before `app.MapHub<WorkerHub>("/hub");`, add:
|
||||
|
||||
Find:
|
||||
```csharp
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
ClaudeDoDbContext.MigrateAndConfigure(
|
||||
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
|
||||
}
|
||||
|
||||
app.MapHub<WorkerHub>("/hub");
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```csharp
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
ClaudeDoDbContext.MigrateAndConfigure(
|
||||
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var seeder = app.Services.GetRequiredService<DefaultAgentSeeder>();
|
||||
var seedResult = await seeder.SeedMissingAsync();
|
||||
app.Logger.LogInformation(
|
||||
"Default agents seeded: {Copied} copied, {Skipped} already present",
|
||||
seedResult.Copied, seedResult.Skipped);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogWarning(ex, "Default agent seeding failed");
|
||||
}
|
||||
|
||||
app.MapHub<WorkerHub>("/hub");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build worker to verify compile**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Program.cs
|
||||
git commit -m "feat(worker): seed default agents on startup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Add RestoreDefaultAgents hub method (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`
|
||||
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||
|
||||
The existing `AgentSettingsHubTests` doesn't exercise `WorkerHub` directly (it tests the repository). We'll add a new test file that tests the seeder restore flow end-to-end without SignalR plumbing — constructing the seeder with temp dirs and asserting the `SeedResult` round-trip. This mirrors how the file is structured today and keeps tests simple.
|
||||
|
||||
- [ ] **Step 1: Add a restore test**
|
||||
|
||||
Add to `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`. Inside the existing `AgentSettingsHubTests` class (before the closing brace), add:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task RestoreDefaultAgents_CopiesMissingBundledFiles()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"claudedo_hub_restore_{Guid.NewGuid():N}");
|
||||
var bundleDir = Path.Combine(root, "bundle");
|
||||
var targetDir = Path.Combine(root, "target");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
Directory.CreateDirectory(targetDir);
|
||||
await File.WriteAllTextAsync(Path.Combine(bundleDir, "code-reviewer.md"), "body");
|
||||
|
||||
var seeder = new ClaudeDo.Worker.Services.DefaultAgentSeeder(bundleDir, targetDir);
|
||||
var result = await seeder.SeedMissingAsync();
|
||||
|
||||
Assert.Equal(1, result.Copied);
|
||||
Assert.Equal(0, result.Skipped);
|
||||
Assert.True(File.Exists(Path.Combine(targetDir, "code-reviewer.md")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(root, true); } catch { }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to confirm it passes (seeder already exists)**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentSettingsHubTests.RestoreDefaultAgents_CopiesMissingBundledFiles"`
|
||||
Expected: 1 test passes.
|
||||
|
||||
- [ ] **Step 3: Add RestoreDefaultAgents to WorkerHub**
|
||||
|
||||
Edit `src/ClaudeDo.Worker/Hub/WorkerHub.cs`.
|
||||
|
||||
Add a new DTO record near the top with the other DTOs (after the `ListConfigDto` line on line 30):
|
||||
|
||||
```csharp
|
||||
public record SeedResultDto(int Copied, int Skipped);
|
||||
```
|
||||
|
||||
Update the class field block. Find:
|
||||
```csharp
|
||||
private readonly QueueService _queue;
|
||||
private readonly AgentFileService _agentService;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly WorktreeMaintenanceService _wtMaintenance;
|
||||
private readonly TaskResetService _resetService;
|
||||
private readonly TaskMergeService _mergeService;
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```csharp
|
||||
private readonly QueueService _queue;
|
||||
private readonly AgentFileService _agentService;
|
||||
private readonly DefaultAgentSeeder _seeder;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly WorktreeMaintenanceService _wtMaintenance;
|
||||
private readonly TaskResetService _resetService;
|
||||
private readonly TaskMergeService _mergeService;
|
||||
```
|
||||
|
||||
Update the constructor. Find:
|
||||
```csharp
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
AgentFileService agentService,
|
||||
HubBroadcaster broadcaster,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
WorktreeMaintenanceService wtMaintenance,
|
||||
TaskResetService resetService,
|
||||
TaskMergeService mergeService)
|
||||
{
|
||||
_queue = queue;
|
||||
_agentService = agentService;
|
||||
_broadcaster = broadcaster;
|
||||
_dbFactory = dbFactory;
|
||||
_wtMaintenance = wtMaintenance;
|
||||
_resetService = resetService;
|
||||
_mergeService = mergeService;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```csharp
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
AgentFileService agentService,
|
||||
DefaultAgentSeeder seeder,
|
||||
HubBroadcaster broadcaster,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
WorktreeMaintenanceService wtMaintenance,
|
||||
TaskResetService resetService,
|
||||
TaskMergeService mergeService)
|
||||
{
|
||||
_queue = queue;
|
||||
_agentService = agentService;
|
||||
_seeder = seeder;
|
||||
_broadcaster = broadcaster;
|
||||
_dbFactory = dbFactory;
|
||||
_wtMaintenance = wtMaintenance;
|
||||
_resetService = resetService;
|
||||
_mergeService = mergeService;
|
||||
}
|
||||
```
|
||||
|
||||
Then add the hub method. After the existing `RefreshAgents` method (currently line 126):
|
||||
|
||||
```csharp
|
||||
public async Task<SeedResultDto> RestoreDefaultAgents()
|
||||
{
|
||||
var result = await _seeder.SeedMissingAsync();
|
||||
return new SeedResultDto(result.Copied, result.Skipped);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build worker to verify compile**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 5: Run all Worker tests to confirm no regressions**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs
|
||||
git commit -m "feat(worker): expose RestoreDefaultAgents hub method"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Add RestoreDefaultAgentsAsync to WorkerClient
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||
|
||||
- [ ] **Step 1: Add the DTO record**
|
||||
|
||||
At the bottom of `src/ClaudeDo.Ui/Services/WorkerClient.cs`, alongside the other public record declarations (after `ListConfigDto`, currently line 350), add:
|
||||
|
||||
```csharp
|
||||
public sealed record SeedResultDto(int Copied, int Skipped);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the client method**
|
||||
|
||||
In the `WorkerClient` class, after the `RefreshAgentsAsync` method (currently line 232), add:
|
||||
|
||||
```csharp
|
||||
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build UI to verify compile**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
|
||||
git commit -m "feat(ui): add RestoreDefaultAgentsAsync to WorkerClient"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Add restore button to Settings modal
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
|
||||
|
||||
- [ ] **Step 1: Add the command to the viewmodel**
|
||||
|
||||
In `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`, add a new command method. Place it after the existing `ConfirmResetAll` method (currently ending line 162), before the `OpenPath` method:
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async Task RestoreDefaultAgents()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "";
|
||||
try
|
||||
{
|
||||
var result = await _worker.RestoreDefaultAgentsAsync();
|
||||
if (result is null)
|
||||
StatusMessage = "Worker offline.";
|
||||
else if (result.Copied == 0 && result.Skipped == 0)
|
||||
StatusMessage = "No default agents bundled.";
|
||||
else if (result.Copied == 0)
|
||||
StatusMessage = "All default agents already present.";
|
||||
else
|
||||
StatusMessage = $"Restored {result.Copied} default agent(s).";
|
||||
|
||||
await _worker.RefreshAgentsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Restore failed: {ex.Message}";
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a button to the settings view**
|
||||
|
||||
Edit `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`. Add a new section after the `WORKTREES` StackPanel block (which ends with `</StackPanel>` around line 185) and before the `ABOUT` section (`<!-- ABOUT -->` around line 187).
|
||||
|
||||
Find:
|
||||
```xml
|
||||
</StackPanel>
|
||||
|
||||
<!-- ABOUT -->
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```xml
|
||||
</StackPanel>
|
||||
|
||||
<!-- AGENTS -->
|
||||
<StackPanel Spacing="0">
|
||||
<TextBlock Classes="section-label" Text="AGENTS"/>
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Restore bundled default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher). Existing files are not overwritten."
|
||||
FontSize="11"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextDimBrush}"/>
|
||||
<Button Content="Restore default agents"
|
||||
Command="{Binding RestoreDefaultAgentsCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ABOUT -->
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build UI to verify compile**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 4: Manual smoke test**
|
||||
|
||||
Start the worker and the UI. Open Settings (3-dots next to username). The AGENTS section should appear with a "Restore default agents" button.
|
||||
|
||||
1. With `~/.todo-app/agents/` empty (delete any existing `.md` files first, back them up if needed): click the button. Status should read "Restored N default agent(s)." The files should appear in the folder.
|
||||
2. Click again. Status should read "All default agents already present."
|
||||
3. Modify one of the restored files. Click restore. The modified file content should be preserved.
|
||||
|
||||
If any step fails, stop and fix before committing.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
|
||||
git commit -m "feat(ui): add Restore default agents button to Settings modal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
At the end, run the full test suite and build all projects:
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
||||
dotnet test tests/ClaudeDo.Worker.Tests
|
||||
```
|
||||
|
||||
Expected: all builds succeed, all tests pass.
|
||||
|
||||
Additionally, start the Worker once with an empty `~/.todo-app/agents/` folder and confirm the log line:
|
||||
> `Default agents seeded: 6 copied, 0 already present`
|
||||
|
||||
Then confirm `~/.todo-app/agents/` contains all 6 markdown files.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
927
docs/superpowers/plans/2026-04-23-planning-sessions-plan-c-ui.md
Normal file
927
docs/superpowers/plans/2026-04-23-planning-sessions-plan-c-ui.md
Normal file
@@ -0,0 +1,927 @@
|
||||
# Planning Sessions — Plan C: UI 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:** Wire the planning-session feature into the UI: context-menu entries, hierarchical display of parent + children, draft styling, unfinished-session dialog, and the `WorkerClient` methods that call the hub endpoints built in Plan B.
|
||||
|
||||
**Architecture:** Extend `TaskRowViewModel` with hierarchy-aware flags (`IsChild`, `IsPlanningParent`, `IsExpanded`). `TasksIslandViewModel` builds a flat stream that interleaves parents and their children based on expanded state. Context-menu entries on `TaskRowView` gate on task status. Draft styling lives in the existing island styles. A modal dialog reuses the project's `TaskCompletionSource<T>` pattern.
|
||||
|
||||
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`), compiled bindings, SignalR client.
|
||||
|
||||
**Spec reference:** `docs/superpowers/specs/2026-04-23-planning-sessions-design.md` section 6.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisite Gate
|
||||
|
||||
This plan depends on Plan A being merged to `main`. Plan B's interface contract (hub method names, return types) is locked in the spec §6.6 and Plan B task 13 — this plan can proceed in parallel with Plan B.
|
||||
|
||||
Before starting:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
git pull --ff-only
|
||||
ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs
|
||||
```
|
||||
|
||||
If the file is missing, wait for Plan A:
|
||||
```bash
|
||||
while ! ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs >/dev/null 2>&1; do
|
||||
echo "Waiting for Plan A to merge..."
|
||||
sleep 60
|
||||
git fetch origin main && git pull --ff-only
|
||||
done
|
||||
```
|
||||
|
||||
Then branch:
|
||||
```bash
|
||||
git checkout -b feat/planning-sessions-ui
|
||||
```
|
||||
|
||||
**Parallel-with-Plan-B note:** Plan B may not yet be merged when this plan runs. The `WorkerClient` methods in Task 9 will compile against Plan B's SignalR hub method names (they're string-based SignalR invocations), so they don't have a build-time dependency. Runtime end-to-end testing requires Plan B merged; until then, mock-test what's possible and smoke-test manually once both plans land.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Modified:**
|
||||
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — add `ParentTaskId`, `IsChild`, `IsPlanningParent`, `IsExpanded`, `PlanningBadge` properties.
|
||||
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — add planning commands, expanded-state map, flat-stream rebuild logic.
|
||||
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — chevron, indentation, badges, draft styling hooks.
|
||||
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs` — context-menu event handlers (if code-behind is used; else inline).
|
||||
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` — use the extended TaskRowView template.
|
||||
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — five new hub method wrappers matching Plan B.
|
||||
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` — `.draft`, `.planning-parent`, `.planned-parent`, badge styles.
|
||||
|
||||
**Created:**
|
||||
- `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` + `.axaml.cs` — modal Resume/Finalize/Discard dialog.
|
||||
- `src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs` — dialog VM.
|
||||
- `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` — VM-level tests.
|
||||
- `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs` — VM-level tests.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend `TaskRowViewModel`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
||||
- Create: `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing test for planning flags**
|
||||
|
||||
Create the test file. Adapt the existing `TaskRowViewModelTests` pattern (look at `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs` for how VMs are constructed in tests):
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.UiVm;
|
||||
|
||||
public sealed class TaskRowViewModelPlanningTests
|
||||
{
|
||||
[Fact]
|
||||
public void Draft_Status_SetsIsChildFlag_WhenParentIdIsNotNull()
|
||||
{
|
||||
// Adapt the constructor call to your actual TaskRowViewModel signature (see TaskRowViewModelTests).
|
||||
var vm = TestHelpers.MakeRow(
|
||||
status: "draft",
|
||||
parentTaskId: "parent-id");
|
||||
|
||||
Assert.True(vm.IsChild);
|
||||
Assert.False(vm.IsPlanningParent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Planning_Status_SetsIsPlanningParent()
|
||||
{
|
||||
var vm = TestHelpers.MakeRow(status: "planning", parentTaskId: null);
|
||||
|
||||
Assert.True(vm.IsPlanningParent);
|
||||
Assert.False(vm.IsChild);
|
||||
Assert.Equal("PLANNING", vm.PlanningBadge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Planned_Status_ShowsPlannedBadge()
|
||||
{
|
||||
var vm = TestHelpers.MakeRow(status: "planned", parentTaskId: null);
|
||||
|
||||
Assert.True(vm.IsPlanningParent);
|
||||
Assert.Equal("PLANNED", vm.PlanningBadge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonPlanningStatus_NoBadge()
|
||||
{
|
||||
var vm = TestHelpers.MakeRow(status: "manual", parentTaskId: null);
|
||||
|
||||
Assert.False(vm.IsPlanningParent);
|
||||
Assert.Null(vm.PlanningBadge);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class TestHelpers
|
||||
{
|
||||
public static TaskRowViewModel MakeRow(string status, string? parentTaskId)
|
||||
{
|
||||
// Implement based on actual TaskRowViewModel constructor.
|
||||
// The TaskRowViewModelTests.cs file in the same folder shows the existing pattern.
|
||||
throw new NotImplementedException("Adapt to your TaskRowViewModel constructor");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Open `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs` first to see how the VM is constructed in tests, then fill in `TestHelpers.MakeRow` accordingly.
|
||||
|
||||
- [ ] **Step 2: Run; verify fail**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRowViewModelPlanningTests"`
|
||||
Expected: FAIL (properties not yet on VM).
|
||||
|
||||
- [ ] **Step 3: Extend the VM**
|
||||
|
||||
In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` add the new properties using `[ObservableProperty]`:
|
||||
|
||||
```csharp
|
||||
[ObservableProperty] private string? parentTaskId;
|
||||
[ObservableProperty] private bool isExpanded = true;
|
||||
|
||||
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
||||
public bool IsPlanningParent => string.Equals(Status, "planning", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(Status, "planned", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public string? PlanningBadge => Status switch
|
||||
{
|
||||
string s when string.Equals(s, "planning", StringComparison.OrdinalIgnoreCase) => "PLANNING",
|
||||
string s when string.Equals(s, "planned", StringComparison.OrdinalIgnoreCase) => "PLANNED",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public bool IsDraft => string.Equals(Status, "draft", StringComparison.OrdinalIgnoreCase);
|
||||
```
|
||||
|
||||
Since `IsChild`, `IsPlanningParent`, `PlanningBadge`, and `IsDraft` are computed from other observables, you must raise property-changed notifications when `Status` or `ParentTaskId` changes. Use `[ObservableProperty]` partial methods:
|
||||
|
||||
```csharp
|
||||
partial void OnStatusChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsPlanningParent));
|
||||
OnPropertyChanged(nameof(PlanningBadge));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
}
|
||||
|
||||
partial void OnParentTaskIdChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsChild));
|
||||
}
|
||||
```
|
||||
|
||||
If the existing VM already has `OnStatusChanged` (check for generator outputs), merge into it rather than duplicating.
|
||||
|
||||
- [ ] **Step 4: Run; verify pass**
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs
|
||||
git commit -m "feat(ui): TaskRowViewModel gains planning hierarchy flags"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `WorkerClient` planning methods
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||
- Create: DTOs matching Plan B return types (either inline in the client file or new file `src/ClaudeDo.Ui/Services/PlanningDtos.cs`).
|
||||
|
||||
- [ ] **Step 1: Add DTOs**
|
||||
|
||||
Create `src/ClaudeDo.Ui/Services/PlanningDtos.cs`:
|
||||
|
||||
```csharp
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed record PlanningSessionFilesDto(
|
||||
string SessionDirectory,
|
||||
string McpConfigPath,
|
||||
string SystemPromptPath,
|
||||
string InitialPromptPath);
|
||||
|
||||
public sealed record PlanningSessionStartInfo(
|
||||
string ParentTaskId,
|
||||
string WorkingDir,
|
||||
PlanningSessionFilesDto Files);
|
||||
|
||||
public sealed record PlanningSessionResumeInfo(
|
||||
string ParentTaskId,
|
||||
string WorkingDir,
|
||||
string ClaudeSessionId,
|
||||
string McpConfigPath);
|
||||
```
|
||||
|
||||
These field names must match Plan B's `PlanningSessionStartContext` / `PlanningSessionResumeContext` exactly (case-sensitive JSON deserialization through SignalR).
|
||||
|
||||
- [ ] **Step 2: Add `WorkerClient` methods**
|
||||
|
||||
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add:
|
||||
|
||||
```csharp
|
||||
public Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||
=> _connection.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
|
||||
|
||||
public Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||
=> _connection.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
|
||||
|
||||
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||
=> _connection.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
|
||||
|
||||
public Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
|
||||
=> _connection.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
|
||||
|
||||
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
|
||||
=> _connection.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
|
||||
```
|
||||
|
||||
Replace `_connection` with whatever name the existing `WorkerClient` uses for its `HubConnection` field.
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: builds.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Services/PlanningDtos.cs src/ClaudeDo.Ui/Services/WorkerClient.cs
|
||||
git commit -m "feat(ui): WorkerClient planning-session methods"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `TasksIslandViewModel` — planning commands + expanded state
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
|
||||
- Create: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`
|
||||
|
||||
- [ ] **Step 1: Add commands to the VM**
|
||||
|
||||
In `TasksIslandViewModel.cs`, add:
|
||||
|
||||
```csharp
|
||||
private readonly Dictionary<string, bool> _expandedState = new();
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || !string.Equals(row.Status, "manual", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
try
|
||||
{
|
||||
await _workerClient.StartPlanningSessionAsync(row.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _dialogs.ShowErrorAsync("Could not start planning session", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || !row.IsPlanningParent) return;
|
||||
try
|
||||
{
|
||||
await _workerClient.ResumePlanningSessionAsync(row.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _dialogs.ShowErrorAsync("Could not resume planning session", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
var confirm = await _dialogs.ConfirmAsync(
|
||||
"Discard planning session?",
|
||||
"This will delete all draft tasks and reset the parent to Manual.");
|
||||
if (!confirm) return;
|
||||
await _workerClient.DiscardPlanningSessionAsync(row.Id);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
await _workerClient.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleExpand(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : true);
|
||||
_expandedState[row.Id] = next;
|
||||
row.IsExpanded = next;
|
||||
RebuildFlatStreams();
|
||||
}
|
||||
|
||||
private void RebuildFlatStreams()
|
||||
{
|
||||
// Existing code builds OpenItems/CompletedItems from the task list.
|
||||
// Modify it so that: after emitting a parent, if IsPlanningParent && IsExpanded,
|
||||
// its Draft / Manual / Queued / Running / Done children are emitted next.
|
||||
// Children already know they are children (ParentTaskId != null) and are styled as such.
|
||||
}
|
||||
```
|
||||
|
||||
The existing `RebuildFlatStreams` (or equivalent) probably just groups tasks by status. You need to intersperse the hierarchy:
|
||||
|
||||
```csharp
|
||||
// Pseudocode — fit to the existing code shape.
|
||||
var topLevel = allRows.Where(r => !r.IsChild).OrderBy(r => r.SortOrder);
|
||||
var flat = new List<TaskRowViewModel>();
|
||||
foreach (var parent in topLevel)
|
||||
{
|
||||
flat.Add(parent);
|
||||
if (parent.IsPlanningParent && parent.IsExpanded)
|
||||
{
|
||||
var children = allRows
|
||||
.Where(r => r.ParentTaskId == parent.Id)
|
||||
.OrderBy(r => r.SortOrder)
|
||||
.ToList();
|
||||
flat.AddRange(children);
|
||||
}
|
||||
}
|
||||
// Then bucket `flat` into OpenItems/CompletedItems like today, preserving order.
|
||||
```
|
||||
|
||||
Pass dependencies: the VM already has a `WorkerClient` or equivalent — reuse it. Add a dialog service if not already injected:
|
||||
|
||||
```csharp
|
||||
public interface IDialogService
|
||||
{
|
||||
Task<bool> ConfirmAsync(string title, string message);
|
||||
Task ShowErrorAsync(string title, string message);
|
||||
}
|
||||
```
|
||||
|
||||
If an analog already exists (check existing editor dialogs), use it.
|
||||
|
||||
- [ ] **Step 2: Write failing VM tests**
|
||||
|
||||
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.UiVm;
|
||||
|
||||
public sealed class TasksIslandViewModelPlanningTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToggleExpand_CollapsesChildrenOfPlanningParent()
|
||||
{
|
||||
// Arrange: create VM with one Planning parent and two Draft children.
|
||||
// Act: call ToggleExpandCommand with the parent.
|
||||
// Assert: flat stream no longer contains the children.
|
||||
// Adapt to how the existing TasksIslandViewModel is instantiated.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenPlanningSessionCommand_ManualTaskOnly_CanExecuteTrue()
|
||||
{
|
||||
// Arrange VM with a Manual row.
|
||||
// Assert CanExecute for OpenPlanningSession command is true for Manual rows,
|
||||
// false for Queued/Running/Done/Failed rows.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These are skeleton tests — implement with the same construction pattern used by the existing `TasksIslandViewModelTests` if one exists, or build a minimal VM fake with a stub `WorkerClient`.
|
||||
|
||||
- [ ] **Step 3: Build + test**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TasksIslandViewModelPlanningTests"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
|
||||
git commit -m "feat(ui): planning commands and expand/collapse in TasksIslandViewModel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `TaskRowView` — indent, chevron, badges, draft styling
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||
|
||||
- [ ] **Step 1: Wrap the row content with a Grid that has an indent column**
|
||||
|
||||
Open `TaskRowView.axaml`. The existing root is likely a `Grid` or `Border`. Replace/refactor the top-level layout to:
|
||||
|
||||
```xml
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Border Grid.Column="0"
|
||||
Width="24"
|
||||
IsVisible="{Binding IsChild}">
|
||||
<Rectangle Width="1" Fill="{DynamicResource TextFaintBrush}" HorizontalAlignment="Right" Margin="0,4"/>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Column="1" ColumnDefinitions="Auto,*,Auto">
|
||||
<!-- Chevron for planning parents -->
|
||||
<Button Grid.Column="0"
|
||||
Classes="icon-btn chevron"
|
||||
Width="18" Height="18"
|
||||
IsVisible="{Binding IsPlanningParent}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleExpandCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<PathIcon Width="10" Height="10">
|
||||
<PathIcon.Data>
|
||||
<MultiBinding Converter="{StaticResource ChevronDataConverter}">
|
||||
<Binding Path="IsExpanded"/>
|
||||
</MultiBinding>
|
||||
</PathIcon.Data>
|
||||
</PathIcon>
|
||||
</Button>
|
||||
|
||||
<!-- existing title/description area -->
|
||||
<StackPanel Grid.Column="1" ...>
|
||||
<!-- existing title binding with added italic when IsDraft -->
|
||||
<TextBlock Text="{Binding Title}"
|
||||
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalicConverter}}"
|
||||
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacityConverter}}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Badges -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
|
||||
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
|
||||
<TextBlock Text="DRAFT"/>
|
||||
</Border>
|
||||
<Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}">
|
||||
<TextBlock Text="{Binding PlanningBadge}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
```
|
||||
|
||||
This is a structural edit — preserve all existing bindings for status color, completion toggle, star, scheduled-for, etc. The new indentation column and badges are additive.
|
||||
|
||||
- [ ] **Step 2: Add the converters**
|
||||
|
||||
If `ChevronDataConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter` do not exist, add them to `src/ClaudeDo.Ui/Converters/` (or inline as compiled converters). Example inline:
|
||||
|
||||
```xml
|
||||
<!-- in UserControl.Resources of TaskRowView.axaml, or in App.axaml for global -->
|
||||
<Style Selector="Border.badge">
|
||||
<Setter Property="CornerRadius" Value="3"/>
|
||||
<Setter Property="Padding" Value="4,1"/>
|
||||
<Setter Property="Background" Value="{DynamicResource BadgeBgBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.badge.draft">
|
||||
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.badge.planning">
|
||||
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
|
||||
</Style>
|
||||
```
|
||||
|
||||
If converters must be code-based, a minimal `BoolToItalicConverter`:
|
||||
|
||||
```csharp
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public sealed class BoolToItalicConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
|
||||
=> value is true ? FontStyle.Italic : FontStyle.Normal;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
```
|
||||
|
||||
Register in `App.axaml` resources.
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: builds cleanly (XAML compiles).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Converters/
|
||||
git commit -m "feat(ui): TaskRowView hierarchy indentation, chevron, badges, draft italic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `TaskRowView` — planning context-menu entries
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||
|
||||
- [ ] **Step 1: Locate the existing context menu**
|
||||
|
||||
Open `TaskRowView.axaml`. The ContextMenu lives somewhere on the root element or as a `ContextMenu.Items`/`ContextFlyout`. Find the block that defines entries like "Edit", "Run now", etc.
|
||||
|
||||
- [ ] **Step 2: Insert planning entries conditionally**
|
||||
|
||||
Add within the existing menu (order: after "Run now" and a separator):
|
||||
|
||||
```xml
|
||||
<MenuItem Header="Open planning Session"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).OpenPlanningSessionCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
IsVisible="{Binding Status, Converter={StaticResource IsManualAndNotChildConverter}, ConverterParameter={Binding IsChild}}"/>
|
||||
|
||||
<MenuItem Header="Resume planning Session"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ResumePlanningSessionCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
IsVisible="{Binding Status, Converter={StaticResource IsPlanningConverter}}"/>
|
||||
|
||||
<MenuItem Header="Discard planning session"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).DiscardPlanningSessionCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
IsVisible="{Binding Status, Converter={StaticResource IsPlanningConverter}}"/>
|
||||
```
|
||||
|
||||
Simpler alternative without multi-condition converters: expose direct bool VM properties that combine the logic — `CanOpenPlanningSession`, `CanResumePlanningSession`, `CanDiscardPlanningSession`:
|
||||
|
||||
```csharp
|
||||
// In TaskRowViewModel
|
||||
public bool CanOpenPlanningSession =>
|
||||
string.Equals(Status, "manual", StringComparison.OrdinalIgnoreCase) && !IsChild;
|
||||
|
||||
public bool CanResumeOrDiscardPlanning =>
|
||||
string.Equals(Status, "planning", StringComparison.OrdinalIgnoreCase);
|
||||
```
|
||||
|
||||
Add `OnPropertyChanged(nameof(CanOpenPlanningSession))` and friends in the status/parent-id partial methods from Task 1. Then the XAML simplifies to:
|
||||
|
||||
```xml
|
||||
<MenuItem Header="Open planning Session"
|
||||
Command="{Binding ...OpenPlanningSessionCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
IsVisible="{Binding CanOpenPlanningSession}"/>
|
||||
```
|
||||
|
||||
Use this simpler path — cleaner.
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: builds.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
|
||||
git commit -m "feat(ui): planning entries in task context menu"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Island styles — draft, badges
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
|
||||
|
||||
- [ ] **Step 1: Add brushes + styles**
|
||||
|
||||
Append within `<Styles.Resources>` or wherever brushes are defined:
|
||||
|
||||
```xml
|
||||
<SolidColorBrush x:Key="DraftBadgeBrush" Color="#4A5568"/>
|
||||
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="#D69E2E"/>
|
||||
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="#3182CE"/>
|
||||
```
|
||||
|
||||
Add styles:
|
||||
|
||||
```xml
|
||||
<Style Selector="Border.badge">
|
||||
<Setter Property="CornerRadius" Value="3"/>
|
||||
<Setter Property="Padding" Value="4,1"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.badge > TextBlock">
|
||||
<Setter Property="FontSize" Value="9"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.badge.draft">
|
||||
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.badge.planning">
|
||||
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.badge.planned">
|
||||
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
|
||||
</Style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build and manually verify**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Launch app, create a task, right-click, use Open planning Session (if Plan B merged) or simulate via DB. Verify badge + italic draft rendering visually.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml
|
||||
git commit -m "feat(ui): draft and planning badge styles"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Unfinished-planning-session dialog
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` + `.axaml.cs`
|
||||
- Create: `src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs`
|
||||
|
||||
- [ ] **Step 1: Create the VM**
|
||||
|
||||
`src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs`:
|
||||
|
||||
```csharp
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Dialogs;
|
||||
|
||||
public enum UnfinishedPlanningDialogResult
|
||||
{
|
||||
Cancel,
|
||||
Resume,
|
||||
FinalizeNow,
|
||||
Discard,
|
||||
}
|
||||
|
||||
public sealed partial class UnfinishedPlanningDialogViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string title = "Unfinished planning session";
|
||||
[ObservableProperty] private string taskTitle = "";
|
||||
[ObservableProperty] private int draftCount;
|
||||
|
||||
public TaskCompletionSource<UnfinishedPlanningDialogResult> Result { get; } = new();
|
||||
|
||||
[RelayCommand] private void Resume() => Result.TrySetResult(UnfinishedPlanningDialogResult.Resume);
|
||||
[RelayCommand] private void FinalizeNow() => Result.TrySetResult(UnfinishedPlanningDialogResult.FinalizeNow);
|
||||
[RelayCommand] private void Discard() => Result.TrySetResult(UnfinishedPlanningDialogResult.Discard);
|
||||
[RelayCommand] private void Cancel() => Result.TrySetResult(UnfinishedPlanningDialogResult.Cancel);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the view**
|
||||
|
||||
`src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml`:
|
||||
|
||||
```xml
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Dialogs"
|
||||
x:Class="ClaudeDo.Ui.Views.Dialogs.UnfinishedPlanningDialog"
|
||||
x:DataType="vm:UnfinishedPlanningDialogViewModel"
|
||||
Width="440" Height="220"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="False"
|
||||
Title="{Binding Title}">
|
||||
<StackPanel Margin="20" Spacing="12">
|
||||
<TextBlock Text="{Binding Title}" FontWeight="Bold" FontSize="15"/>
|
||||
<TextBlock Text="{Binding TaskTitle}" Opacity="0.85"/>
|
||||
<TextBlock>
|
||||
<Run Text="{Binding DraftCount}"/>
|
||||
<Run Text=" draft tasks waiting to be finalized."/>
|
||||
</TextBlock>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||
<Button Content="Discard" Command="{Binding DiscardCommand}"/>
|
||||
<Button Content="Finalize now" Command="{Binding FinalizeNowCommand}"/>
|
||||
<Button Content="Resume" Classes="accent" Command="{Binding ResumeCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
```
|
||||
|
||||
`UnfinishedPlanningDialog.axaml.cs`:
|
||||
|
||||
```csharp
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Dialogs;
|
||||
|
||||
public partial class UnfinishedPlanningDialog : Window
|
||||
{
|
||||
public UnfinishedPlanningDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire into `TasksIslandViewModel`**
|
||||
|
||||
When the user right-clicks a `Planning` row OR when the app starts and a `Planning` row is present, show the dialog. Add a helper in the VM:
|
||||
|
||||
```csharp
|
||||
private async Task<UnfinishedPlanningDialogResult> AskUnfinishedPlanningAsync(TaskRowViewModel row)
|
||||
{
|
||||
var dialogVm = new UnfinishedPlanningDialogViewModel
|
||||
{
|
||||
TaskTitle = row.Title,
|
||||
DraftCount = await _workerClient.GetPendingDraftCountAsync(row.Id),
|
||||
};
|
||||
var dlg = new UnfinishedPlanningDialog { DataContext = dialogVm };
|
||||
_ = dlg.ShowDialog(_ownerWindow);
|
||||
return await dialogVm.Result.Task;
|
||||
}
|
||||
```
|
||||
|
||||
Replace the direct resume/discard/finalize commands (from Task 3) with calls that first pop this dialog and dispatch based on result. For example:
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || !row.IsPlanningParent) return;
|
||||
var choice = await AskUnfinishedPlanningAsync(row);
|
||||
switch (choice)
|
||||
{
|
||||
case UnfinishedPlanningDialogResult.Resume:
|
||||
await _workerClient.ResumePlanningSessionAsync(row.Id);
|
||||
break;
|
||||
case UnfinishedPlanningDialogResult.FinalizeNow:
|
||||
await _workerClient.FinalizePlanningSessionAsync(row.Id);
|
||||
break;
|
||||
case UnfinishedPlanningDialogResult.Discard:
|
||||
await _workerClient.DiscardPlanningSessionAsync(row.Id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build + manual run**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: builds.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml.cs src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
|
||||
git commit -m "feat(ui): unfinished planning session dialog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: TasksIslandView — wire new templates
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
|
||||
|
||||
- [ ] **Step 1: No structural change required**
|
||||
|
||||
The hierarchy is already handled by `TasksIslandViewModel.RebuildFlatStreams` interleaving children into `OpenItems`/`CompletedItems`. The existing `ItemsControl` bindings in `TasksIslandView` automatically pick up the new rows. Indentation/chevron/badge rendering is entirely inside `TaskRowView` (Task 4).
|
||||
|
||||
Verify the view does not have any logic that filters out children based on `ParentTaskId IS NOT NULL` today. If it does, remove that filter — the VM is now authoritative about what's in the stream.
|
||||
|
||||
- [ ] **Step 2: Build + manual check**
|
||||
|
||||
Launch the UI, create a manual task, and manually update its status to `Planning` in the DB (or wait for Plan B). Create one child in DB. Verify indentation and chevron render.
|
||||
|
||||
- [ ] **Step 3: Commit (if any change was made)**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
|
||||
git commit -m "chore(ui): verify tasks view renders hierarchy via flat stream"
|
||||
```
|
||||
|
||||
If no change — skip the commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Delete-with-children handling
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (existing delete command)
|
||||
|
||||
- [ ] **Step 1: Catch `DbUpdateException` from delete**
|
||||
|
||||
Find the existing delete command. Wrap the repository/hub call:
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async Task RemoveAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
try
|
||||
{
|
||||
await _workerClient.DeleteTaskAsync(row.Id);
|
||||
}
|
||||
catch (HubException ex) when (ex.Message.Contains("foreign key", StringComparison.OrdinalIgnoreCase)
|
||||
|| ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|
||||
|| ex.Message.Contains("Restrict", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var childrenCount = 1; // or query via a new hub method if exact count matters
|
||||
var choice = await _dialogs.ConfirmAsync(
|
||||
"Cannot delete",
|
||||
$"This task has child tasks. Delete all including children?");
|
||||
if (!choice) return;
|
||||
// Recursive delete — iterate children first. For v1 MVP, instruct user to
|
||||
// discard the planning session first. Simpler, safer.
|
||||
await _dialogs.ShowErrorAsync(
|
||||
"Cannot delete",
|
||||
"Discard the planning session or delete child tasks manually first.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Simplification for v1:** do not implement "Delete all including children" yet. Show an error instructing the user to discard the planning session or delete children first. This avoids an additional hub endpoint and keeps Plan C bounded.
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: builds.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
|
||||
git commit -m "feat(ui): friendly error when deleting task with children"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Manual smoke test + final verification
|
||||
|
||||
**Files:** none
|
||||
|
||||
- [ ] **Step 1: Full test run**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 2: Build the full app**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
|
||||
```
|
||||
Expected: all succeed.
|
||||
|
||||
- [ ] **Step 3: Manual smoke test (requires Plan B merged)**
|
||||
|
||||
1. Launch the app.
|
||||
2. Create a Manual task with a title and some TODO-style description.
|
||||
3. Right-click → "Open planning Session".
|
||||
4. Verify Windows Terminal opens with Claude CLI running.
|
||||
5. In the terminal, ask Claude to create two child tasks (`mcp__claudedo__create_child_task`).
|
||||
6. Watch the UI: drafts appear under the parent (italic, grey, badge DRAFT).
|
||||
7. Ask Claude to `finalize`.
|
||||
8. Verify drafts become Manual/Queued children, parent flips to PLANNED badge.
|
||||
9. Close terminal without finalize on a new planning task; right-click the Planning task: dialog appears with Resume/Finalize/Discard.
|
||||
|
||||
- [ ] **Step 4: Document any UI tweaks needed in `docs/open.md`**
|
||||
|
||||
Add a checklist item under UI verification for planning session visuals.
|
||||
|
||||
- [ ] **Step 5: Final commit**
|
||||
|
||||
```bash
|
||||
git add docs/open.md
|
||||
git commit -m "docs(open): add planning-session manual verification checklist"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Out of scope for Plan C
|
||||
|
||||
- Recursive delete of parent-with-children via UI (error-only in v1).
|
||||
- Collapse-state persistence across app restarts (in-memory only).
|
||||
- Keyboard shortcut for "Open planning Session".
|
||||
- Visual differentiation for PLANNED parents beyond a badge (e.g., subtle background tint) — can be added later if visually needed.
|
||||
1831
docs/superpowers/plans/2026-04-23-self-update.md
Normal file
1831
docs/superpowers/plans/2026-04-23-self-update.md
Normal file
File diff suppressed because it is too large
Load Diff
718
docs/superpowers/plans/2026-04-23-worker-log-footer.md
Normal file
718
docs/superpowers/plans/2026-04-23-worker-log-footer.md
Normal file
@@ -0,0 +1,718 @@
|
||||
# Worker Log Footer 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:** Surface important Worker lifecycle events in the UI footer as a single rotating, color-coded line that auto-hides after 30s of silence.
|
||||
|
||||
**Architecture:** Add `WorkerLogLevel` enum in shared `ClaudeDo.Data` project. `HubBroadcaster` gets a `WorkerLog(message, level, timestampUtc)` SignalR event. Seven emit sites in `TaskRunner`, `TaskMergeService`, `TaskResetService` (callers of `WorktreeManager`, not WorktreeManager itself — they have the task title in scope). UI side: `WorkerClient` surfaces a `WorkerLogReceived` event; footer state lives on `IslandsShellViewModel` (existing root VM for `MainWindow`, also owns connection state); `System.Timers.Timer` clears the line after 30s; a `WorkerLogLevelToBrushConverter` maps level → brush in XAML.
|
||||
|
||||
**Tech Stack:** .NET 8, ASP.NET Core SignalR, Avalonia 12, CommunityToolkit.Mvvm, xUnit.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-23-worker-log-footer-design.md`
|
||||
|
||||
**Deviation from spec:** Spec names `WorktreeManager.CreateAsync` / `DiscardAsync` as emit sites. In practice, `WorktreeManager` has only the task ID in scope; its callers (`TaskRunner`, `TaskResetService`) have the title. Emitting from callers avoids adding constructor dependencies to `WorktreeManager` and produces identical user-visible behavior.
|
||||
|
||||
**Build note:** Per project convention, `dotnet build ClaudeDo.slnx` fails on .NET 8 — always build individual csprojs.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `WorkerLogLevel` enum (shared contract)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Data/Models/WorkerLogLevel.cs`
|
||||
|
||||
- [ ] **Step 1: Write the enum**
|
||||
|
||||
```csharp
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public enum WorkerLogLevel
|
||||
{
|
||||
Info,
|
||||
Success,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build Data project**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
|
||||
Expected: Build succeeded, 0 errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Data/Models/WorkerLogLevel.cs
|
||||
git commit -m "feat(data): add WorkerLogLevel enum"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add `WorkerLog` broadcaster method + SignalR JSON enum-as-string
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` (append method)
|
||||
- Modify: `src/ClaudeDo.Worker/Program.cs` (line ~23 — the `AddSignalR()` call)
|
||||
|
||||
- [ ] **Step 1: Add enum-as-string serialization**
|
||||
|
||||
Replace:
|
||||
```csharp
|
||||
builder.Services.AddSignalR();
|
||||
```
|
||||
with:
|
||||
```csharp
|
||||
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
||||
{
|
||||
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `WorkerLog` method to `HubBroadcaster`**
|
||||
|
||||
Add the following method inside `HubBroadcaster` class (after the existing `RunCreated` method):
|
||||
|
||||
```csharp
|
||||
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
|
||||
_hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
|
||||
```
|
||||
|
||||
Add to the using block at top of file (if not already present):
|
||||
```csharp
|
||||
using ClaudeDo.Data.Models;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: Build succeeded, 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Program.cs
|
||||
git commit -m "feat(worker): add WorkerLog SignalR event"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Emit `WorkerLog` from `TaskRunner`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
|
||||
|
||||
Four emit sites in this file:
|
||||
1. **Created worktree** — right after `_wtManager.CreateAsync` succeeds (around line 69).
|
||||
2. **Started Claude** — just before invoking Claude process.
|
||||
3. **Committed changes** — after auto-commit (before the `WorktreeUpdated` broadcast around line 318).
|
||||
4. **Finished** — at both success (line 330) and failure paths, mirroring the existing `TaskFinished` call.
|
||||
|
||||
- [ ] **Step 1: Add using for `WorkerLogLevel`**
|
||||
|
||||
Ensure `TaskRunner.cs` has at the top:
|
||||
```csharp
|
||||
using ClaudeDo.Data.Models;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Emit "Created worktree"**
|
||||
|
||||
After the line `wtCtx = await _wtManager.CreateAsync(task, list, ct);` (around line 69), add:
|
||||
```csharp
|
||||
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
|
||||
```
|
||||
(Place inside the same `if` branch that called `CreateAsync`, after the assignment.)
|
||||
|
||||
- [ ] **Step 3: Emit "Started Claude"**
|
||||
|
||||
Locate the point just before `ClaudeProcess` is invoked (search for where `ClaudeProcess` or `RunProcessAsync` is called). Just before the invocation, add:
|
||||
```csharp
|
||||
await _broadcaster.WorkerLog($"Started Claude for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Emit "Committed changes"**
|
||||
|
||||
Locate the auto-commit code path (around line 318, just before `await _broadcaster.WorktreeUpdated(task.Id);`). Add immediately before that call:
|
||||
```csharp
|
||||
await _broadcaster.WorkerLog($"Committed changes in \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Emit "Finished (done)"**
|
||||
|
||||
Find the success finish path (around line 330, where `TaskFinished` is broadcast with status `"done"`). Add immediately before that call:
|
||||
```csharp
|
||||
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Emit "Finished (failed)"**
|
||||
|
||||
Find the failure path (search for `TaskFinished` with status `"failed"`). Add immediately before:
|
||||
```csharp
|
||||
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: Build succeeded, 0 errors.
|
||||
|
||||
- [ ] **Step 8: Run existing Worker tests (no regressions)**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
|
||||
git commit -m "feat(worker): emit WorkerLog events from TaskRunner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Emit `WorkerLog` from `TaskMergeService` and `TaskResetService`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs`
|
||||
- Modify: `src/ClaudeDo.Worker/Services/TaskResetService.cs`
|
||||
|
||||
Both services already have `HubBroadcaster` injected (`_broadcaster`). Both already load the task entity (needed for title).
|
||||
|
||||
- [ ] **Step 1: Add using in both files**
|
||||
|
||||
Add to the top of each file (if not already present):
|
||||
```csharp
|
||||
using ClaudeDo.Data.Models;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Emit "Merged" in `TaskMergeService.MergeAsync`**
|
||||
|
||||
Locate the existing log line around line 137:
|
||||
```csharp
|
||||
_logger.LogInformation(
|
||||
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
|
||||
...);
|
||||
```
|
||||
|
||||
Immediately after it (before `return new MergeResult(...)` on line 140), add:
|
||||
```csharp
|
||||
await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||
```
|
||||
Use whatever variable names `task` and `targetBranch` are in scope — adjust to match the actual local names at that site.
|
||||
|
||||
- [ ] **Step 3: Emit "Discarded" in `TaskResetService.ResetAsync`**
|
||||
|
||||
Locate the call `await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);` (line 53). Immediately after it, add:
|
||||
```csharp
|
||||
await _broadcaster.WorkerLog($"Discarded worktree for \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Emit "Reset" in `TaskResetService.ResetAsync`**
|
||||
|
||||
Locate the existing line `_logger.LogInformation("Reset task {TaskId} to Manual ...` (line 66). Immediately after it, add:
|
||||
```csharp
|
||||
await _broadcaster.WorkerLog($"Reset \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: Build succeeded, 0 errors.
|
||||
|
||||
- [ ] **Step 6: Run existing Worker tests**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Services/TaskMergeService.cs src/ClaudeDo.Worker/Services/TaskResetService.cs
|
||||
git commit -m "feat(worker): emit WorkerLog for merge, discard, reset"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add `WorkerLogEntry` record + `WorkerLogReceived` event on `WorkerClient`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||
|
||||
- [ ] **Step 1: Add using for `WorkerLogLevel`**
|
||||
|
||||
Add at the top of `WorkerClient.cs`:
|
||||
```csharp
|
||||
using ClaudeDo.Data.Models;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Declare the `WorkerLogEntry` record**
|
||||
|
||||
Add at the top of the file (above or below the `WorkerClient` class, same namespace):
|
||||
```csharp
|
||||
public sealed record WorkerLogEntry(string Message, WorkerLogLevel Level, DateTime TimestampUtc);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the event field**
|
||||
|
||||
Alongside the other `public event Action<...>?` declarations (around lines 42-48), add:
|
||||
```csharp
|
||||
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Register the SignalR handler**
|
||||
|
||||
Alongside the other `_hub.On<...>` registrations (around lines 80-117), add:
|
||||
```csharp
|
||||
_hub.On<string, WorkerLogLevel, DateTime>("WorkerLog", (message, level, timestampUtc) =>
|
||||
{
|
||||
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: Build succeeded, 0 errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
|
||||
git commit -m "feat(ui): subscribe to WorkerLog SignalR event"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Create `ClaudeDo.Ui.Tests` project and add `WorkerLogLevelToBrushConverter`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/App.axaml` (register converter as resource)
|
||||
- Create: `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
|
||||
- Create: `tests/ClaudeDo.Ui.Tests/WorkerLogLevelToBrushConverterTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the converter**
|
||||
|
||||
Create `src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public sealed class WorkerLogLevelToBrushConverter : IValueConverter
|
||||
{
|
||||
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#4CAF50"));
|
||||
private static readonly IBrush WarnBrush = new SolidColorBrush(Color.Parse("#FFA726"));
|
||||
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#EF5350"));
|
||||
private static readonly IBrush InfoFallback = new SolidColorBrush(Color.Parse("#888888"));
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not WorkerLogLevel level)
|
||||
return AvaloniaProperty.UnsetValue;
|
||||
|
||||
return level switch
|
||||
{
|
||||
WorkerLogLevel.Success => SuccessBrush,
|
||||
WorkerLogLevel.Warn => WarnBrush,
|
||||
WorkerLogLevel.Error => ErrorBrush,
|
||||
WorkerLogLevel.Info => ResolveInfoBrush(),
|
||||
_ => AvaloniaProperty.UnsetValue,
|
||||
};
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
private static IBrush ResolveInfoBrush()
|
||||
{
|
||||
if (Application.Current is { } app &&
|
||||
app.Resources.TryGetResource("TextDimBrush", app.ActualThemeVariant, out var res) &&
|
||||
res is IBrush brush)
|
||||
{
|
||||
return brush;
|
||||
}
|
||||
return InfoFallback;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register converter in `App.axaml`**
|
||||
|
||||
Open `src/ClaudeDo.Ui/App.axaml`. Inside the `<Application.Resources>` section (add one if missing), add alongside any existing converter entries:
|
||||
|
||||
```xml
|
||||
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
|
||||
```
|
||||
|
||||
Ensure the `xmlns:converters="using:ClaudeDo.Ui.Converters"` namespace is declared at the root `<Application>` element. If other converters (e.g. `StatusColorConverter`) are already resources in `App.axaml` follow the same pattern; if they're declared per-view, declare this converter at the top of `MainWindow.axaml` in Task 8 instead.
|
||||
|
||||
- [ ] **Step 3: Create UI test project**
|
||||
|
||||
Create `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Headless" Version="12.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ClaudeDo.Ui\ClaudeDo.Ui.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
If the existing `tests/ClaudeDo.Worker.Tests/*.csproj` uses different `Microsoft.NET.Test.Sdk` / xUnit versions, match those versions exactly to avoid analyzer mismatches.
|
||||
|
||||
- [ ] **Step 4: Write the failing test**
|
||||
|
||||
Create `tests/ClaudeDo.Ui.Tests/WorkerLogLevelToBrushConverterTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Converters;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
public class WorkerLogLevelToBrushConverterTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(WorkerLogLevel.Success, "#FF4CAF50")]
|
||||
[InlineData(WorkerLogLevel.Warn, "#FFFFA726")]
|
||||
[InlineData(WorkerLogLevel.Error, "#FFEF5350")]
|
||||
public void Convert_maps_level_to_expected_brush_color(WorkerLogLevel level, string expectedArgb)
|
||||
{
|
||||
var converter = new WorkerLogLevelToBrushConverter();
|
||||
|
||||
var result = converter.Convert(level, typeof(IBrush), null, CultureInfo.InvariantCulture);
|
||||
|
||||
var solid = Assert.IsType<SolidColorBrush>(result);
|
||||
Assert.Equal(expectedArgb.ToLowerInvariant(), $"#{solid.Color.ToUInt32():X8}".ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_info_returns_a_brush_fallback_when_no_app()
|
||||
{
|
||||
var converter = new WorkerLogLevelToBrushConverter();
|
||||
|
||||
var result = converter.Convert(WorkerLogLevel.Info, typeof(IBrush), null, CultureInfo.InvariantCulture);
|
||||
|
||||
Assert.IsAssignableFrom<IBrush>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_unknown_value_returns_unset()
|
||||
{
|
||||
var converter = new WorkerLogLevelToBrushConverter();
|
||||
|
||||
var result = converter.Convert("not a level", typeof(IBrush), null, CultureInfo.InvariantCulture);
|
||||
|
||||
Assert.Equal(AvaloniaProperty.UnsetValue, result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
|
||||
Expected: All 5 tests pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs src/ClaudeDo.Ui/App.axaml tests/ClaudeDo.Ui.Tests/
|
||||
git commit -m "feat(ui): add WorkerLogLevelToBrushConverter with tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Add footer state + 30s auto-clear timer to `IslandsShellViewModel`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
||||
- Create: `tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs`
|
||||
|
||||
Timer uses `System.Timers.Timer` (not `DispatcherTimer`) so unit tests don't need an Avalonia dispatcher. The elapsed callback marshals to the UI thread via `Dispatcher.UIThread.Post` when the dispatcher is available; in tests the VM logic under test sets properties directly so no marshalling is needed.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
public class IslandsShellViewModelWorkerLogTests
|
||||
{
|
||||
private static IslandsShellViewModel NewVm() =>
|
||||
// The real constructor requires island VMs + WorkerClient. These tests
|
||||
// only exercise the WorkerLog handling, so we use a test-only constructor
|
||||
// that bypasses the sub-VMs. Add `internal IslandsShellViewModel()` for tests.
|
||||
IslandsShellViewModel.CreateForTests();
|
||||
|
||||
[Fact]
|
||||
public void Receiving_event_sets_text_level_and_visible()
|
||||
{
|
||||
var vm = NewVm();
|
||||
var at = new DateTime(2026, 4, 23, 14, 32, 0, DateTimeKind.Utc);
|
||||
|
||||
vm.OnWorkerLogReceived(new WorkerLogEntry("Created worktree for \"X\"", WorkerLogLevel.Info, at));
|
||||
|
||||
Assert.True(vm.IsWorkerLogVisible);
|
||||
Assert.Equal(WorkerLogLevel.Info, vm.WorkerLogLevel);
|
||||
Assert.Contains("Created worktree for \"X\"", vm.WorkerLogText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Second_event_replaces_first()
|
||||
{
|
||||
var vm = NewVm();
|
||||
vm.OnWorkerLogReceived(new WorkerLogEntry("first", WorkerLogLevel.Info, DateTime.UtcNow));
|
||||
vm.OnWorkerLogReceived(new WorkerLogEntry("second", WorkerLogLevel.Success, DateTime.UtcNow));
|
||||
|
||||
Assert.Contains("second", vm.WorkerLogText);
|
||||
Assert.Equal(WorkerLogLevel.Success, vm.WorkerLogLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearWorkerLog_hides_line()
|
||||
{
|
||||
var vm = NewVm();
|
||||
vm.OnWorkerLogReceived(new WorkerLogEntry("msg", WorkerLogLevel.Info, DateTime.UtcNow));
|
||||
|
||||
vm.ClearWorkerLog();
|
||||
|
||||
Assert.False(vm.IsWorkerLogVisible);
|
||||
Assert.Null(vm.WorkerLogText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Text_is_formatted_as_HHmm_dot_message_local_time()
|
||||
{
|
||||
var vm = NewVm();
|
||||
var utc = new DateTime(2026, 4, 23, 12, 0, 0, DateTimeKind.Utc);
|
||||
var expectedLocalHhmm = utc.ToLocalTime().ToString("HH:mm");
|
||||
|
||||
vm.OnWorkerLogReceived(new WorkerLogEntry("hello", WorkerLogLevel.Info, utc));
|
||||
|
||||
Assert.StartsWith(expectedLocalHhmm + " · ", vm.WorkerLogText);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm they fail to compile**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
|
||||
Expected: Build errors — `CreateForTests`, `OnWorkerLogReceived`, `ClearWorkerLog`, `IsWorkerLogVisible`, `WorkerLogText`, `WorkerLogLevel` do not yet exist.
|
||||
|
||||
- [ ] **Step 3: Implement on `IslandsShellViewModel`**
|
||||
|
||||
Open `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`. Add `using`s if missing:
|
||||
|
||||
```csharp
|
||||
using System.Timers;
|
||||
using Avalonia.Threading;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Services;
|
||||
```
|
||||
|
||||
Inside the class, add:
|
||||
|
||||
```csharp
|
||||
[ObservableProperty] private string? workerLogText;
|
||||
[ObservableProperty] private WorkerLogLevel workerLogLevel;
|
||||
[ObservableProperty] private bool isWorkerLogVisible;
|
||||
|
||||
private readonly Timer _workerLogTimer = new(TimeSpan.FromSeconds(30).TotalMilliseconds)
|
||||
{
|
||||
AutoReset = false,
|
||||
};
|
||||
|
||||
internal static IslandsShellViewModel CreateForTests() =>
|
||||
(IslandsShellViewModel)System.Runtime.Serialization.FormatterServices
|
||||
.GetUninitializedObject(typeof(IslandsShellViewModel));
|
||||
```
|
||||
|
||||
(If `FormatterServices` is unavailable under `net8.0`, instead add a parameterless `internal IslandsShellViewModel() {}` constructor guarded for tests only.)
|
||||
|
||||
In the existing real constructor, wire up subscription (after the line `Worker.PropertyChanged += ...` block, around line 63-70):
|
||||
|
||||
```csharp
|
||||
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
||||
_workerLogTimer.Elapsed += (_, _) =>
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess()) ClearWorkerLog();
|
||||
else Dispatcher.UIThread.Post(ClearWorkerLog);
|
||||
};
|
||||
```
|
||||
|
||||
Add the methods the tests call:
|
||||
|
||||
```csharp
|
||||
public void OnWorkerLogReceived(WorkerLogEntry entry)
|
||||
{
|
||||
var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
|
||||
WorkerLogText = $"{hhmm} · {entry.Message}";
|
||||
WorkerLogLevel = entry.Level;
|
||||
IsWorkerLogVisible = true;
|
||||
|
||||
_workerLogTimer.Stop();
|
||||
_workerLogTimer.Start();
|
||||
}
|
||||
|
||||
public void ClearWorkerLog()
|
||||
{
|
||||
IsWorkerLogVisible = false;
|
||||
WorkerLogText = null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
|
||||
Expected: All tests pass (5 converter + 4 VM = 9 tests).
|
||||
|
||||
- [ ] **Step 5: Build the UI project as a sanity check**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs
|
||||
git commit -m "feat(ui): add worker log state and 30s timer to shell VM"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Update `MainWindow.axaml` footer — dock log line right
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml` (lines 104-135 — the footer `Border`)
|
||||
|
||||
- [ ] **Step 1: Add the converter resource to the window (if not already in App.axaml)**
|
||||
|
||||
If Task 6 declared the converter in `App.axaml`, skip this step. Otherwise, add a `<Window.Resources>` block near the top of `MainWindow.axaml`:
|
||||
```xml
|
||||
<Window.Resources>
|
||||
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
|
||||
</Window.Resources>
|
||||
```
|
||||
Ensure `xmlns:converters="using:ClaudeDo.Ui.Converters"` is declared on the root `<Window>`.
|
||||
|
||||
- [ ] **Step 2: Replace the footer body**
|
||||
|
||||
Replace the existing footer `<Border Grid.Row="2" ...>` inner contents (the `<StackPanel>` at lines 109-134) with:
|
||||
|
||||
```xml
|
||||
<DockPanel LastChildFill="True" Margin="14,0">
|
||||
<!-- Left: connection pill -->
|
||||
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="7"
|
||||
VerticalAlignment="Center">
|
||||
<Ellipse Width="7" Height="7" Fill="#4CAF50"
|
||||
IsVisible="{Binding Worker.IsConnected}"/>
|
||||
<Ellipse Width="7" Height="7" Fill="#FFA726"
|
||||
IsVisible="{Binding Worker.IsReconnecting}"/>
|
||||
<Ellipse Width="7" Height="7" Fill="#EF5350"
|
||||
IsVisible="{Binding IsOffline}"/>
|
||||
<TextBlock Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
|
||||
FontFamily="{DynamicResource MonoFont}"
|
||||
FontSize="10"
|
||||
LetterSpacing="1.4"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Right: worker log line -->
|
||||
<TextBlock DockPanel.Dock="Right"
|
||||
Text="{Binding WorkerLogText}"
|
||||
IsVisible="{Binding IsWorkerLogVisible}"
|
||||
Foreground="{Binding WorkerLogLevel, Converter={StaticResource WorkerLogLevelToBrush}}"
|
||||
FontFamily="{DynamicResource MonoFont}"
|
||||
FontSize="10"
|
||||
LetterSpacing="1.4"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<!-- Spacer (fills remaining space between pill and log) -->
|
||||
<Panel/>
|
||||
</DockPanel>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build the UI**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: Build succeeded, 0 errors. No XAML compilation errors.
|
||||
|
||||
- [ ] **Step 4: Build the full app (entry point)**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 5: Manual smoke test**
|
||||
|
||||
Start the Worker and the App (two separate processes per CLAUDE.md).
|
||||
|
||||
Exercise each event and confirm the footer line appears with the expected color and copy:
|
||||
|
||||
1. Start a task → expect `HH:MM · Created worktree for "<title>"` (dim/info).
|
||||
2. Observe while Claude runs → expect `HH:MM · Started Claude for "<title>"` (dim/info).
|
||||
3. Task commits → expect `HH:MM · Committed changes in "<title>"` (dim/info).
|
||||
4. Task finishes successfully → expect `HH:MM · Finished "<title>" (done)` (green).
|
||||
5. Trigger a failing task → expect `HH:MM · Finished "<title>" (failed)` (red).
|
||||
6. Reset a failed task → expect `HH:MM · Discarded worktree for "<title>"` (amber) followed by `HH:MM · Reset "<title>"` (amber).
|
||||
7. Merge a completed task → expect `HH:MM · Merged "<title>" into <branch>` (green).
|
||||
8. Wait 30s with no new events → footer log line disappears (connection pill remains).
|
||||
9. Trigger a burst of 3 events in quick succession → only the most recent is shown; timer resets on each.
|
||||
10. Long task title (≥60 chars) → line is ellipsized, connection pill on the left remains fully visible.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Views/MainWindow.axaml
|
||||
git commit -m "feat(ui): show worker log line in footer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:**
|
||||
- Enum `WorkerLogLevel` in `ClaudeDo.Data` — Task 1 ✓
|
||||
- SignalR enum-as-string — Task 2 ✓
|
||||
- `HubBroadcaster.WorkerLog` — Task 2 ✓
|
||||
- 7 emit sites with correct level mapping — Tasks 3, 4 ✓
|
||||
- `WorkerClient.WorkerLogReceived` event + `WorkerLogEntry` record — Task 5 ✓
|
||||
- `WorkerLogLevelToBrushConverter` with unit tests — Task 6 ✓
|
||||
- Footer VM state + 30s timer + tests — Task 7 ✓
|
||||
- Footer XAML (DockPanel, connection left, log right, level-based color, ellipsis) — Task 8 ✓
|
||||
- Out-of-scope items (history drawer, filtering, persistence) — correctly omitted ✓
|
||||
|
||||
- **Placeholder scan:** No "TBD" / "handle edge cases" / "similar to Task N". All code is inline.
|
||||
|
||||
- **Type consistency:** `WorkerLogEntry(Message, Level, TimestampUtc)` — same signature used in Task 5 (declaration), Task 7 (consumer tests + VM). `WorkerLog(message, level, timestampUtc)` — same signature in Task 2 (broadcaster) and Tasks 3-4 (callers). `OnWorkerLogReceived` / `ClearWorkerLog` / `IsWorkerLogVisible` / `WorkerLogText` / `WorkerLogLevel` — consistent between Task 7 test and Task 7 implementation.
|
||||
1918
docs/superpowers/plans/2026-04-24-planning-merge-all.md
Normal file
1918
docs/superpowers/plans/2026-04-24-planning-merge-all.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,799 @@
|
||||
# Planning UX Polish + Sequential Subtask Queue — 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:** Add sequential execution of planning subtasks (new `Waiting` status, context-menu trigger, worker-side chain advancement) plus three small UX changes (auto-collapse done planning parents in the task list, collapsible Description in the Details pane, narrower island GridSplitters).
|
||||
|
||||
**Architecture:** Foundation first — add the new `Waiting` enum value and its surface in the UI (chip, virtual-queued filter, row plumbing). Then ship the three UI polish items independently. Finally build the worker-side chain coordinator behind TDD and wire up the SignalR method + context-menu entry.
|
||||
|
||||
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm, EF Core (Sqlite), SignalR, xUnit.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-24-planning-ux-and-sequential-subtasks-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `Waiting` status to the enum
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs` (TaskStatus enum)
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` (chip class switch)
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (virtual-queued match predicate)
|
||||
|
||||
- [ ] **Step 1: Add `Waiting` to the enum**
|
||||
|
||||
Append `Waiting` as the last value (keeps existing numeric slots stable for any int-serialized rows).
|
||||
|
||||
`src/ClaudeDo.Data/Models/TaskEntity.cs`:
|
||||
|
||||
```csharp
|
||||
public enum TaskStatus
|
||||
{
|
||||
Manual,
|
||||
Queued,
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
Planning,
|
||||
Planned,
|
||||
Draft,
|
||||
Waiting,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend `StatusChipClass` switch in TaskRowViewModel**
|
||||
|
||||
`src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — update the switch:
|
||||
|
||||
```csharp
|
||||
public string StatusChipClass => Status switch
|
||||
{
|
||||
TaskStatus.Running => "running",
|
||||
TaskStatus.Failed => "error",
|
||||
TaskStatus.Done => "review",
|
||||
TaskStatus.Queued => "queued",
|
||||
TaskStatus.Waiting => "waiting",
|
||||
_ => "idle",
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `IsWaiting` and include it in virtual-queued matching**
|
||||
|
||||
In the same `TaskRowViewModel.cs`, add alongside `IsQueued`:
|
||||
|
||||
```csharp
|
||||
public bool IsWaiting => Status == TaskStatus.Waiting;
|
||||
```
|
||||
|
||||
In `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`, find the `TaskMatchesList` static method and update the `virtual:queued` branch so tasks in `Waiting` also match. Locate the existing match for `ListKind.Virtual when list.Id == "virtual:queued"` and change it to match `t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting`. If the existing line reads `t.Status == TaskStatus.Queued` exactly, replace it with `t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting`.
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
||||
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||
```
|
||||
|
||||
Expected: both build with 0 errors. Existing warnings OK.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Data/Models/TaskEntity.cs \
|
||||
src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
|
||||
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
|
||||
git commit -m "feat(data): add Waiting task status and include it in virtual:queued"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Narrower island GridSplitters
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml` (lines 158 and 170)
|
||||
|
||||
- [ ] **Step 1: Halve the splitter width**
|
||||
|
||||
Both `GridSplitter` elements currently use `Width="5"`. Change both to `Width="3"`. Leave all other attributes untouched.
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Views/MainWindow.axaml
|
||||
git commit -m "style(ui): narrow island GridSplitters from 5 to 3"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Collapsible Description section in Details pane
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
|
||||
|
||||
- [ ] **Step 1: Add observable flag + toggle command**
|
||||
|
||||
In `DetailsIslandViewModel.cs`, add beside the existing editable fields:
|
||||
|
||||
```csharp
|
||||
[ObservableProperty] private bool _isDescriptionExpanded = true;
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Reset flag when a new task is loaded**
|
||||
|
||||
Find the method that handles a new `Task` being bound (the existing `OnTaskChanged` / `Bind` path — it's the spot that already sets `EditableTitle`, `EditableDescription`, etc.). At the start of the load path where fields get reset, add:
|
||||
|
||||
```csharp
|
||||
IsDescriptionExpanded = true;
|
||||
```
|
||||
|
||||
(If the reset is scattered, put it next to the `EditableDescription = ""` assignment.)
|
||||
|
||||
- [ ] **Step 3: Wrap the description TextBox in a collapsible section**
|
||||
|
||||
In `DetailsIslandView.axaml`, locate the description TextBox. Wrap it so it looks like:
|
||||
|
||||
```xml
|
||||
<StackPanel Spacing="4">
|
||||
<Button Classes="flat"
|
||||
Command="{Binding ToggleDescriptionExpandedCommand}"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Padding="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<PathIcon Width="10" Height="10"
|
||||
Data="{StaticResource Icon.ChevronDown}"
|
||||
IsVisible="{Binding IsDescriptionExpanded}"/>
|
||||
<PathIcon Width="10" Height="10"
|
||||
Data="{StaticResource Icon.ChevronRight}"
|
||||
IsVisible="{Binding !IsDescriptionExpanded}"/>
|
||||
<TextBlock Classes="eyebrow" Text="DESCRIPTION"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- existing description TextBox goes here unchanged, but add: -->
|
||||
<TextBox ...existing attributes...
|
||||
IsVisible="{Binding IsDescriptionExpanded}"/>
|
||||
</StackPanel>
|
||||
```
|
||||
|
||||
If the existing `Icon.ChevronDown` / `Icon.ChevronRight` static resources don't exist, inspect `App.axaml` (or wherever `StaticResource Icon.*` icons live) and pick the closest existing chevron pair. If only one direction exists, use a simple `▾` / `▸` TextBlock substitute:
|
||||
|
||||
```xml
|
||||
<TextBlock Text="▾" IsVisible="{Binding IsDescriptionExpanded}"/>
|
||||
<TextBlock Text="▸" IsVisible="{Binding !IsDescriptionExpanded}"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 5: Manual verify**
|
||||
|
||||
Launch the app (`dotnet run --project src/ClaudeDo.App`), open a task with a description, click the chevron. Verify the body collapses/expands; verify opening a different task restores the expanded default.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs \
|
||||
src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml
|
||||
git commit -m "feat(ui): collapsible description section in details pane"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Auto-collapse done planning parents in task list
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||
|
||||
- [ ] **Step 1: Add expansion state + "all children done" flag to `TaskRowViewModel`**
|
||||
|
||||
In `TaskRowViewModel.cs`, add below the existing observable properties:
|
||||
|
||||
```csharp
|
||||
[ObservableProperty] private bool _areChildrenExpanded = true;
|
||||
[ObservableProperty] private bool _allChildrenDone;
|
||||
|
||||
partial void OnAllChildrenDoneChanged(bool value)
|
||||
{
|
||||
// Default children to collapsed once the planning parent is fully done.
|
||||
if (value) AreChildrenExpanded = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleChildrenExpanded() => AreChildrenExpanded = !AreChildrenExpanded;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Compute `AllChildrenDone` during Regroup in `TasksIslandViewModel`**
|
||||
|
||||
In `TasksIslandViewModel.cs`, locate the `Regroup()` method (the one that clears and repopulates `OverdueItems`/`OpenItems`/`CompletedItems`). Before it distributes rows, build a lookup of children by parent id:
|
||||
|
||||
```csharp
|
||||
var childrenByParent = Items
|
||||
.Where(r => r.IsChild && r.ParentTaskId is not null)
|
||||
.GroupBy(r => r.ParentTaskId!)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild))
|
||||
{
|
||||
if (childrenByParent.TryGetValue(parent.Id, out var kids) && kids.Count > 0)
|
||||
parent.AllChildrenDone = kids.All(c => c.Status == TaskStatus.Done);
|
||||
else
|
||||
parent.AllChildrenDone = false;
|
||||
}
|
||||
```
|
||||
|
||||
Then inside the existing distribution loop, skip child rows whose parent row has `AreChildrenExpanded == false`:
|
||||
|
||||
```csharp
|
||||
foreach (var row in Items)
|
||||
{
|
||||
if (row.IsChild && row.ParentTaskId is not null)
|
||||
{
|
||||
var parentRow = Items.FirstOrDefault(p => p.Id == row.ParentTaskId);
|
||||
if (parentRow is not null && !parentRow.AreChildrenExpanded) continue;
|
||||
}
|
||||
// ... existing distribution into Overdue/Open/Completed ...
|
||||
}
|
||||
```
|
||||
|
||||
If `Regroup()` currently uses LINQ expressions instead of a loop, split them out into explicit foreach so the skip is clear. Keep the overdue/completed logic intact — children of a collapsed parent are excluded from every bucket.
|
||||
|
||||
- [ ] **Step 3: Re-run Regroup when a row's expansion flag toggles**
|
||||
|
||||
In `TasksIslandViewModel.cs`, in the constructor (after `Items` is created), subscribe to changes so toggling one row triggers a regroup:
|
||||
|
||||
```csharp
|
||||
Items.CollectionChanged += (_, e) =>
|
||||
{
|
||||
if (e.NewItems is not null)
|
||||
foreach (TaskRowViewModel r in e.NewItems)
|
||||
r.PropertyChanged += OnItemPropertyChanged;
|
||||
if (e.OldItems is not null)
|
||||
foreach (TaskRowViewModel r in e.OldItems)
|
||||
r.PropertyChanged -= OnItemPropertyChanged;
|
||||
};
|
||||
```
|
||||
|
||||
Add the handler:
|
||||
|
||||
```csharp
|
||||
private void OnItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(TaskRowViewModel.AreChildrenExpanded))
|
||||
Regroup();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add chevron toggle button to the planning-parent row**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, inside the main task card where the title/eyebrow row lives (co-located with `PlanningBadge`), add a chevron button visible only when `IsPlanningParent && HasPlanningChildren`:
|
||||
|
||||
```xml
|
||||
<Button Classes="flat"
|
||||
Command="{Binding ToggleChildrenExpandedCommand}"
|
||||
IsVisible="{Binding HasPlanningChildren}"
|
||||
Padding="0" Margin="0,0,6,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock FontSize="10"
|
||||
Text="▾"
|
||||
IsVisible="{Binding AreChildrenExpanded}"/>
|
||||
<TextBlock FontSize="10"
|
||||
Text="▸"
|
||||
IsVisible="{Binding !AreChildrenExpanded}"/>
|
||||
</Button>
|
||||
```
|
||||
|
||||
Place it immediately before the title TextBlock in the parent-row layout. Leave child rows untouched.
|
||||
|
||||
- [ ] **Step 5: Build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 6: Manual verify**
|
||||
|
||||
Create a planning parent with ≥2 children. Mark both children `Done` (manually via DB if needed, or via a full planning run). Reload the list — the children should be hidden by default. Click the chevron on the parent — children appear. Click again — collapse.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
|
||||
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs \
|
||||
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
|
||||
git commit -m "feat(ui): auto-collapse done planning parents in task list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: PlanningChainCoordinator — worker-side chain advancement (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs`
|
||||
- Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing test — queueing sets first child Queued, rest Waiting**
|
||||
|
||||
Create `tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Threading.Tasks;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Planning;
|
||||
|
||||
public class PlanningChainCoordinatorTests
|
||||
{
|
||||
private static DbContextOptions<ClaudeDoDbContext> InMemoryOptions() =>
|
||||
new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite("DataSource=:memory:;Cache=Shared")
|
||||
.Options;
|
||||
|
||||
private static async Task<(ClaudeDoDbContext ctx, TaskRepository repo)> NewDbAsync()
|
||||
{
|
||||
var ctx = new ClaudeDoDbContext(InMemoryOptions());
|
||||
await ctx.Database.OpenConnectionAsync();
|
||||
await ctx.Database.EnsureCreatedAsync();
|
||||
return (ctx, new TaskRepository(ctx));
|
||||
}
|
||||
|
||||
private static async Task SeedPlanningFamily(TaskRepository repo, string parentId, int childCount)
|
||||
{
|
||||
await repo.AddAsync(new TaskEntity
|
||||
{
|
||||
Id = parentId, ListId = "L1", Title = "Parent",
|
||||
CreatedAt = System.DateTime.UtcNow, Status = TaskStatus.Planned,
|
||||
});
|
||||
for (int i = 0; i < childCount; i++)
|
||||
{
|
||||
await repo.AddAsync(new TaskEntity
|
||||
{
|
||||
Id = $"{parentId}-c{i}", ListId = "L1", Title = $"Child {i}",
|
||||
CreatedAt = System.DateTime.UtcNow, Status = TaskStatus.Manual,
|
||||
ParentTaskId = parentId, SortOrder = i,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueSubtasksSequentially_SetsFirstQueued_RestWaiting()
|
||||
{
|
||||
var (ctx, repo) = await NewDbAsync();
|
||||
await using var _ = ctx;
|
||||
await SeedPlanningFamily(repo, "P", 3);
|
||||
|
||||
var coord = new PlanningChainCoordinator(repo);
|
||||
await coord.QueueSubtasksSequentiallyAsync("P", default);
|
||||
|
||||
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
|
||||
Assert.Equal(TaskStatus.Queued, kids[0].Status);
|
||||
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
|
||||
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test — expect failure (class doesn't exist)**
|
||||
|
||||
```bash
|
||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
|
||||
```
|
||||
|
||||
Expected: compile error "PlanningChainCoordinator not found".
|
||||
|
||||
- [ ] **Step 3: Create the coordinator with the minimum to pass**
|
||||
|
||||
`src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed class PlanningChainCoordinator
|
||||
{
|
||||
private readonly TaskRepository _tasks;
|
||||
|
||||
public PlanningChainCoordinator(TaskRepository tasks) => _tasks = tasks;
|
||||
|
||||
public async Task QueueSubtasksSequentiallyAsync(string parentTaskId, CancellationToken ct)
|
||||
{
|
||||
var parent = await _tasks.GetByIdAsync(parentTaskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {parentTaskId} not found.");
|
||||
|
||||
var children = (await _tasks.GetChildrenAsync(parentTaskId, ct))
|
||||
.OrderBy(t => t.SortOrder)
|
||||
.ToList();
|
||||
if (children.Count == 0)
|
||||
throw new InvalidOperationException("Parent has no subtasks.");
|
||||
|
||||
var bad = children.FirstOrDefault(c => c.Status is not (TaskStatus.Manual or TaskStatus.Planned));
|
||||
if (bad is not null)
|
||||
throw new InvalidOperationException($"Child {bad.Id} is in status {bad.Status}; expected Manual or Planned.");
|
||||
|
||||
for (int i = 0; i < children.Count; i++)
|
||||
{
|
||||
children[i].Status = i == 0 ? TaskStatus.Queued : TaskStatus.Waiting;
|
||||
await _tasks.UpdateAsync(children[i], ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `TaskRepository.GetChildrenAsync` does not yet exist, add it:
|
||||
|
||||
```csharp
|
||||
// in src/ClaudeDo.Data/Repositories/TaskRepository.cs
|
||||
public Task<List<TaskEntity>> GetChildrenAsync(string parentTaskId, CancellationToken ct = default) =>
|
||||
_ctx.Tasks.Where(t => t.ParentTaskId == parentTaskId).ToListAsync(ct);
|
||||
```
|
||||
|
||||
(If the repo uses `AsNoTracking()` elsewhere for reads, match that pattern. For this method we want tracked entities so `UpdateAsync` works without extra attach.)
|
||||
|
||||
- [ ] **Step 4: Run the test — expect pass**
|
||||
|
||||
```bash
|
||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
|
||||
```
|
||||
|
||||
Expected: 1 passed.
|
||||
|
||||
- [ ] **Step 5: Add failing test — on child Done, next Waiting sibling flips to Queued**
|
||||
|
||||
Append to `PlanningChainCoordinatorTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task OnChildDone_FlipsNextWaitingToQueued()
|
||||
{
|
||||
var (ctx, repo) = await NewDbAsync();
|
||||
await using var _ = ctx;
|
||||
await SeedPlanningFamily(repo, "P", 3);
|
||||
|
||||
var coord = new PlanningChainCoordinator(repo);
|
||||
await coord.QueueSubtasksSequentiallyAsync("P", default);
|
||||
|
||||
// Simulate first child finishing Done.
|
||||
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
||||
first.Status = TaskStatus.Done;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var advanced = await coord.OnChildFinishedAsync("P-c0", TaskStatus.Done, default);
|
||||
|
||||
Assert.Equal("P-c1", advanced);
|
||||
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
|
||||
Assert.Equal(TaskStatus.Done, kids[0].Status);
|
||||
Assert.Equal(TaskStatus.Queued, kids[1].Status);
|
||||
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run — expect failure**
|
||||
|
||||
Expected: compile error "OnChildFinishedAsync does not exist".
|
||||
|
||||
- [ ] **Step 7: Implement `OnChildFinishedAsync`**
|
||||
|
||||
In `PlanningChainCoordinator.cs`:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Call after a child task transitions to a terminal status.
|
||||
/// Returns the id of the newly-queued sibling (if any), else null.
|
||||
/// </summary>
|
||||
public async Task<string?> OnChildFinishedAsync(string childTaskId, TaskStatus finalStatus, CancellationToken ct)
|
||||
{
|
||||
if (finalStatus != TaskStatus.Done) return null;
|
||||
|
||||
var child = await _tasks.GetByIdAsync(childTaskId, ct);
|
||||
if (child?.ParentTaskId is null) return null;
|
||||
|
||||
var siblings = (await _tasks.GetChildrenAsync(child.ParentTaskId, ct))
|
||||
.OrderBy(t => t.SortOrder)
|
||||
.ToList();
|
||||
|
||||
var next = siblings
|
||||
.Where(s => s.SortOrder > child.SortOrder && s.Status == TaskStatus.Waiting)
|
||||
.FirstOrDefault();
|
||||
if (next is null) return null;
|
||||
|
||||
next.Status = TaskStatus.Queued;
|
||||
await _tasks.UpdateAsync(next, ct);
|
||||
return next.Id;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run — expect pass**
|
||||
|
||||
```bash
|
||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
|
||||
```
|
||||
|
||||
Expected: 2 passed.
|
||||
|
||||
- [ ] **Step 9: Add failing test — on Failed, chain stops**
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task OnChildFailed_DoesNotAdvanceChain()
|
||||
{
|
||||
var (ctx, repo) = await NewDbAsync();
|
||||
await using var _ = ctx;
|
||||
await SeedPlanningFamily(repo, "P", 3);
|
||||
|
||||
var coord = new PlanningChainCoordinator(repo);
|
||||
await coord.QueueSubtasksSequentiallyAsync("P", default);
|
||||
|
||||
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
||||
first.Status = TaskStatus.Failed;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var advanced = await coord.OnChildFinishedAsync("P-c0", TaskStatus.Failed, default);
|
||||
|
||||
Assert.Null(advanced);
|
||||
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
|
||||
Assert.Equal(TaskStatus.Failed, kids[0].Status);
|
||||
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
|
||||
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Run — expect pass (existing guard handles it)**
|
||||
|
||||
```bash
|
||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
|
||||
```
|
||||
|
||||
Expected: 3 passed.
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs \
|
||||
src/ClaudeDo.Data/Repositories/TaskRepository.cs \
|
||||
tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs
|
||||
git commit -m "feat(worker): add PlanningChainCoordinator with sequential subtask advancement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Hook chain advancement into TaskRunner finish path
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
|
||||
- Modify: `src/ClaudeDo.Worker/Program.cs` (DI registration)
|
||||
|
||||
- [ ] **Step 1: Register `PlanningChainCoordinator` in DI**
|
||||
|
||||
Locate `src/ClaudeDo.Worker/Program.cs` where other services are registered (look for `services.AddSingleton<PlanningSessionManager>` or similar). Add:
|
||||
|
||||
```csharp
|
||||
services.AddScoped<PlanningChainCoordinator>();
|
||||
```
|
||||
|
||||
Use `AddScoped` if `TaskRepository` is scoped (check how it's registered — match its lifetime). If `TaskRepository` is constructed ad-hoc inside the worker, add a constructor overload on `PlanningChainCoordinator` that takes `IDbContextFactory<ClaudeDoDbContext>` and builds its own `TaskRepository` per call, then register as Singleton. Mirror the pattern used by `PlanningSessionManager`.
|
||||
|
||||
- [ ] **Step 2: Inject coordinator into `TaskRunner`**
|
||||
|
||||
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, add `PlanningChainCoordinator` to the constructor parameter list and store it in a readonly field (match the style used for `_broadcaster`).
|
||||
|
||||
If `TaskRunner` is not a good fit for direct injection (e.g., it's used in contexts without DI), instead inject `IServiceProvider` / `IDbContextFactory<ClaudeDoDbContext>` and new-up a coordinator inside the finish handler. Pick whichever matches existing `TaskRunner` patterns.
|
||||
|
||||
- [ ] **Step 3: Call coordinator after Done/Failed emission**
|
||||
|
||||
Immediately after each `await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);` on line ~338 and the two failed emissions on lines ~355 and ~372, add:
|
||||
|
||||
```csharp
|
||||
if (task.ParentTaskId is not null)
|
||||
{
|
||||
var advancedId = await _chainCoordinator.OnChildFinishedAsync(
|
||||
task.Id,
|
||||
/* Done or Failed based on path */,
|
||||
CancellationToken.None);
|
||||
if (advancedId is not null)
|
||||
await _broadcaster.TaskUpdated(advancedId);
|
||||
}
|
||||
```
|
||||
|
||||
Use `TaskStatus.Done` in the done-path call site and `TaskStatus.Failed` in the failed-path call sites. For the failed paths that use `justFailed` rather than `task`, read `justFailed?.ParentTaskId` and `justFailed?.Id` to stay consistent with the surrounding code.
|
||||
|
||||
After this call the existing queue-pickup loop will see the newly-Queued sibling and dispatch it on its next tick.
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 5: Run full test suite**
|
||||
|
||||
```bash
|
||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
|
||||
```
|
||||
|
||||
Expected: all pre-existing tests + 3 new ones pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs \
|
||||
src/ClaudeDo.Worker/Program.cs
|
||||
git commit -m "feat(worker): advance planning subtask chain on child finish"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Hub method + client + context menu entry
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Services/IWorkerClient.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs`
|
||||
|
||||
- [ ] **Step 1: Add hub method**
|
||||
|
||||
In `src/ClaudeDo.Worker/Hub/WorkerHub.cs`, add (match the style of other planning methods):
|
||||
|
||||
```csharp
|
||||
public async Task QueuePlanningSubtasks(string parentTaskId)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var repo = new TaskRepository(ctx);
|
||||
var coord = new PlanningChainCoordinator(repo);
|
||||
await coord.QueueSubtasksSequentiallyAsync(parentTaskId, CancellationToken.None);
|
||||
|
||||
// Broadcast updates for the parent and all its children so the UI refreshes.
|
||||
var children = await ctx.Tasks
|
||||
.Where(t => t.ParentTaskId == parentTaskId)
|
||||
.Select(t => t.Id)
|
||||
.ToListAsync();
|
||||
await _broadcaster.TaskUpdated(parentTaskId);
|
||||
foreach (var id in children)
|
||||
await _broadcaster.TaskUpdated(id);
|
||||
|
||||
// Make sure the queue picks up the now-Queued first child immediately.
|
||||
_queueSignal.Wake();
|
||||
}
|
||||
```
|
||||
|
||||
If the existing hub constructs `PlanningSessionManager` via DI directly, inject `PlanningChainCoordinator` the same way and call `_chainCoordinator.QueueSubtasksSequentiallyAsync(...)` instead of newing one up. If the hub exposes a queue-wakeup via a different name than `_queueSignal`, use that (search the file for `WakeQueue` or `.Wake()`).
|
||||
|
||||
- [ ] **Step 2: Add method to `IWorkerClient`**
|
||||
|
||||
In `src/ClaudeDo.Ui/Services/IWorkerClient.cs`, add next to the other planning methods:
|
||||
|
||||
```csharp
|
||||
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement in `WorkerClient`**
|
||||
|
||||
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add (match the pattern of `StartPlanningSessionAsync` etc.):
|
||||
|
||||
```csharp
|
||||
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) =>
|
||||
_connection.InvokeAsync("QueuePlanningSubtasks", parentTaskId, ct);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `CanQueueSubtasksSequentially` + `HasPlanningChildren` observable to `TaskRowViewModel`**
|
||||
|
||||
Confirm `HasPlanningChildren` exists (it's referenced in the spec). If not, add it as `[ObservableProperty] bool _hasPlanningChildren;` and ensure `TasksIslandViewModel.Regroup()` already sets it (there should be a parent-side "has children" pass similar to the `AllChildrenDone` one added in Task 4 — if not, set it there).
|
||||
|
||||
Then add:
|
||||
|
||||
```csharp
|
||||
public bool CanQueueSubtasksSequentially =>
|
||||
IsPlanningParent && HasPlanningChildren && !IsChild;
|
||||
```
|
||||
|
||||
Add `OnPropertyChanged(nameof(CanQueueSubtasksSequentially))` inside `OnStatusChanged` and `OnHasPlanningChildrenChanged` so the flag refreshes when status or children change.
|
||||
|
||||
- [ ] **Step 5: Add context-menu entry**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, inside the existing `<ContextMenu>`, directly after the "Discard planning session" item:
|
||||
|
||||
```xml
|
||||
<Separator IsVisible="{Binding CanQueueSubtasksSequentially}"/>
|
||||
<MenuItem Header="Queue subtasks sequentially"
|
||||
IsVisible="{Binding CanQueueSubtasksSequentially}"
|
||||
Click="OnQueueSubtasksSequentiallyClick"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add click handler in code-behind**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs`, add (match the other `On*Click` handlers — they pull the `TaskRowViewModel` from `DataContext` and call the shell / worker):
|
||||
|
||||
```csharp
|
||||
private async void OnQueueSubtasksSequentiallyClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not TaskRowViewModel row) return;
|
||||
var worker = App.Services.GetRequiredService<IWorkerClient>();
|
||||
try
|
||||
{
|
||||
await worker.QueuePlanningSubtasksAsync(row.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Match the toast/log pattern used by OnSendToQueueClick et al.
|
||||
System.Diagnostics.Debug.WriteLine($"QueuePlanningSubtasks failed: {ex}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use the same `App.Services` / `IWorkerClient` lookup pattern as `OnSendToQueueClick` — do not introduce a new DI pattern. If the existing handlers use a shell/mediator indirection, use that instead.
|
||||
|
||||
- [ ] **Step 7: Build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 8: Manual verify end-to-end**
|
||||
|
||||
1. Launch app: `dotnet run --project src/ClaudeDo.App`.
|
||||
2. Open a planning task with ≥2 subtasks (all in `Manual`/`Planned`).
|
||||
3. Right-click parent → **Queue subtasks sequentially**.
|
||||
4. Confirm in the task list: first child shows `Queued` chip, others show `Waiting` chip.
|
||||
5. Let the first run to completion (or, for a quick smoke test, edit the DB to mark it `Done` and emit `TaskUpdated` via a restart).
|
||||
6. Confirm the next child's status flips `Waiting → Queued` without user interaction.
|
||||
7. Force-fail a child (cancel it mid-run) — confirm remaining `Waiting` children stay `Waiting`.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs \
|
||||
src/ClaudeDo.Ui/Services/IWorkerClient.cs \
|
||||
src/ClaudeDo.Ui/Services/WorkerClient.cs \
|
||||
src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
|
||||
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml \
|
||||
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs
|
||||
git commit -m "feat(ui+worker): context menu to queue planning subtasks sequentially"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review checklist (for the plan author before handing off)
|
||||
|
||||
- All four spec items mapped: auto-collapse (Task 4), collapsible description (Task 3), narrower splitters (Task 2), sequential subtask queue (Tasks 1, 5, 6, 7).
|
||||
- `Waiting` enum touches: enum, chip class, virtual:queued filter — covered in Task 1.
|
||||
- TDD applied where it pays off (the coordinator); UI tasks rely on manual verification (correct for this codebase).
|
||||
- No placeholders. Every code step shows the code to paste.
|
||||
- Type names consistent: `PlanningChainCoordinator`, `QueueSubtasksSequentiallyAsync`, `OnChildFinishedAsync`, `QueuePlanningSubtasksAsync`, `AreChildrenExpanded`, `AllChildrenDone`, `IsDescriptionExpanded` — used the same across tasks.
|
||||
- Commits are small and conventional.
|
||||
999
docs/superpowers/plans/2026-04-24-planning-worktree-plan.md
Normal file
999
docs/superpowers/plans/2026-04-24-planning-worktree-plan.md
Normal file
@@ -0,0 +1,999 @@
|
||||
# Planning Session Worktree 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:** Make `mcp__claudedo__*` tools available inside planning sessions by running each session in an ephemeral git worktree that holds a project-scope `.mcp.json` and a settings override that auto-trusts project MCP servers.
|
||||
|
||||
**Architecture:** `PlanningSessionManager` creates a short-lived git worktree from `HEAD` of the list's working directory on `StartAsync`, writes `.mcp.json` (with env-var expansion for the bearer token) and `.claude/settings.local.json` into it, and returns the worktree path as the spawn directory. `WindowsTerminalPlanningLauncher` passes the token via env var (`CLAUDEDO_PLANNING_TOKEN`) and stops passing `--mcp-config`. Finalize/Discard force-remove the worktree and branch.
|
||||
|
||||
**Tech Stack:** .NET 8, xUnit, real SQLite (DbFixture), real git worktrees via `ClaudeDo.Data.Git.GitService`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-24-planning-worktree-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Modify:**
|
||||
- `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs` — add `Token`, `WorktreePath`, `BranchName` to start context; add `Token` and rename `McpConfigPath` → `WorktreePath` on resume context
|
||||
- `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs` — drop `McpConfigPath` field
|
||||
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — worktree create/cleanup, token persistence, new ctor deps
|
||||
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs` — env var, drop `--mcp-config`
|
||||
- `src/ClaudeDo.Worker/Program.cs` — DI wiring for new ctor signature
|
||||
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` — add git init, update existing assertions
|
||||
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs` — add git init in setup
|
||||
- `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs` — assert env var, no `--mcp-config`
|
||||
|
||||
Each file has one clear responsibility; no new files needed.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend context records with token and worktree info
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs`
|
||||
|
||||
- [ ] **Step 1: Edit the records**
|
||||
|
||||
Replace the full file content with:
|
||||
|
||||
```csharp
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed record PlanningSessionStartContext(
|
||||
string ParentTaskId,
|
||||
string WorkingDir,
|
||||
string Token,
|
||||
string WorktreePath,
|
||||
string BranchName,
|
||||
PlanningSessionFiles Files);
|
||||
|
||||
public sealed record PlanningSessionResumeContext(
|
||||
string ParentTaskId,
|
||||
string WorkingDir,
|
||||
string ClaudeSessionId,
|
||||
string Token,
|
||||
string WorktreePath);
|
||||
```
|
||||
|
||||
Note: `WorkingDir` on both records now points at the worktree (callers that used it as "spawn dir" remain correct; callers that needed "list working dir" must be updated separately — no such callers exist today).
|
||||
|
||||
- [ ] **Step 2: Build to see breakage**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: FAIL — `PlanningSessionManager` and `WindowsTerminalPlanningLauncher` no longer match these signatures.
|
||||
|
||||
- [ ] **Step 3: Commit stub**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
|
||||
git commit -m "refactor(worker): extend planning contexts with token and worktree"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Drop `McpConfigPath` from `PlanningSessionFiles`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs`
|
||||
|
||||
- [ ] **Step 1: Edit the record**
|
||||
|
||||
Replace the full file content with:
|
||||
|
||||
```csharp
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed record PlanningSessionFiles(
|
||||
string SessionDirectory,
|
||||
string SystemPromptPath,
|
||||
string InitialPromptPath);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
|
||||
git commit -m "refactor(worker): drop McpConfigPath from PlanningSessionFiles"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Extend `PlanningSessionManager` constructors
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (fields + constructors only)
|
||||
|
||||
- [ ] **Step 1: Add using directives**
|
||||
|
||||
At the top of `PlanningSessionManager.cs`, add these imports alongside the existing ones:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Worker.Config;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace fields and constructors**
|
||||
|
||||
Replace the block from `private const string McpServerUrl` down to the end of `CreateRepos()` with:
|
||||
|
||||
```csharp
|
||||
private const string McpServerUrl = "http://127.0.0.1:47821/mcp";
|
||||
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext>? _factory;
|
||||
private readonly TaskRepository? _tasksOverride;
|
||||
private readonly ListRepository? _listsOverride;
|
||||
private readonly AppSettingsRepository? _settingsOverride;
|
||||
private readonly GitService _git;
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly string _rootDirectory;
|
||||
|
||||
// DI constructor.
|
||||
public PlanningSessionManager(
|
||||
IDbContextFactory<ClaudeDoDbContext> factory,
|
||||
GitService git,
|
||||
WorkerConfig cfg,
|
||||
string rootDirectory)
|
||||
{
|
||||
_factory = factory;
|
||||
_git = git;
|
||||
_cfg = cfg;
|
||||
_rootDirectory = rootDirectory;
|
||||
}
|
||||
|
||||
// Test constructor.
|
||||
public PlanningSessionManager(
|
||||
TaskRepository tasks,
|
||||
ListRepository lists,
|
||||
AppSettingsRepository settings,
|
||||
GitService git,
|
||||
WorkerConfig cfg,
|
||||
string rootDirectory)
|
||||
{
|
||||
_tasksOverride = tasks;
|
||||
_listsOverride = lists;
|
||||
_settingsOverride = settings;
|
||||
_git = git;
|
||||
_cfg = cfg;
|
||||
_rootDirectory = rootDirectory;
|
||||
}
|
||||
|
||||
private (TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, ClaudeDoDbContext? ctx) CreateRepos()
|
||||
{
|
||||
if (_tasksOverride is not null)
|
||||
return (_tasksOverride, _listsOverride!, _settingsOverride!, null);
|
||||
var ctx = _factory!.CreateDbContext();
|
||||
return (new TaskRepository(ctx), new ListRepository(ctx), new AppSettingsRepository(ctx), ctx);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update all `CreateRepos()` call-sites in this file**
|
||||
|
||||
Every call currently binds `(tasks, lists, ctx)`. Change each to `(tasks, lists, settings, ctx)` (search the file for `= CreateRepos();`).
|
||||
|
||||
The `_` and `__` discard patterns on the returned `ctx` (lines like `await using var _ = ctx;`) remain valid.
|
||||
|
||||
- [ ] **Step 4: Build — expect test breakage**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: PASS (production code compiles).
|
||||
|
||||
Run: `dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
|
||||
Expected: FAIL — test ctor calls don't match. Will be fixed in Task 10.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
|
||||
git commit -m "refactor(worker): inject GitService and WorkerConfig into PlanningSessionManager"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add a worktree-path helper and the token-file helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (add private helpers)
|
||||
|
||||
- [ ] **Step 1: Add three private helpers at the bottom of the class (before the closing `}`)**
|
||||
|
||||
```csharp
|
||||
private static string BranchNameFor(string taskId) =>
|
||||
$"claudedo/planning/{taskId.Replace("-", "")}";
|
||||
|
||||
private string WorktreePathFor(string taskId, string strategy, string? centralRootOverride, string listWorkingDir)
|
||||
{
|
||||
var centralRoot = !string.IsNullOrWhiteSpace(centralRootOverride)
|
||||
? centralRootOverride!
|
||||
: _cfg.CentralWorktreeRoot;
|
||||
|
||||
var raw = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
|
||||
? Path.Combine(centralRoot, "planning", taskId)
|
||||
: Path.Combine(Path.GetDirectoryName(listWorkingDir)!, ".claudedo-worktrees", "planning", taskId);
|
||||
|
||||
return Path.GetFullPath(raw);
|
||||
}
|
||||
|
||||
private static string TokenFilePathFor(string sessionDir) =>
|
||||
Path.Combine(sessionDir, "token");
|
||||
|
||||
private static async Task WriteTokenFileAsync(string path, string token, CancellationToken ct)
|
||||
{
|
||||
await File.WriteAllTextAsync(path, token, ct);
|
||||
// Best-effort current-user-only ACL on Windows. On non-Windows the inherited
|
||||
// perms from the parent dir apply; acceptable because sessionDir is already
|
||||
// under the user's home (~/.todo-app/sessions/).
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
var fi = new FileInfo(path);
|
||||
var ac = fi.GetAccessControl();
|
||||
ac.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
var me = System.Security.Principal.WindowsIdentity.GetCurrent().User!;
|
||||
ac.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
|
||||
me,
|
||||
System.Security.AccessControl.FileSystemRights.FullControl,
|
||||
System.Security.AccessControl.AccessControlType.Allow));
|
||||
fi.SetAccessControl(ac);
|
||||
}
|
||||
catch { /* ACL hardening is best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadTokenFileAsync(string path, CancellationToken ct)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
throw new InvalidOperationException($"Token file missing: {path}");
|
||||
return (await File.ReadAllTextAsync(path, ct)).Trim();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
|
||||
git commit -m "refactor(worker): add worktree path and token file helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Rewrite `BuildMcpConfigJson` to use env-var expansion
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`
|
||||
|
||||
- [ ] **Step 1: Replace `BuildMcpConfigJson` body**
|
||||
|
||||
Find the existing `private static string BuildMcpConfigJson(string token)` method. Replace with:
|
||||
|
||||
```csharp
|
||||
private static string BuildMcpConfigJson()
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
mcpServers = new
|
||||
{
|
||||
claudedo = new
|
||||
{
|
||||
type = "http",
|
||||
url = McpServerUrl,
|
||||
headers = new Dictionary<string, string>
|
||||
{
|
||||
["Authorization"] = "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
```
|
||||
|
||||
(The token argument is dropped — claude expands `${CLAUDEDO_PLANNING_TOKEN}` at load time from the spawned process environment.)
|
||||
|
||||
- [ ] **Step 2: Also add settings override builder below it**
|
||||
|
||||
```csharp
|
||||
private const string SettingsLocalJson = """
|
||||
{
|
||||
"enableAllProjectMcpServers": true
|
||||
}
|
||||
""";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
|
||||
git commit -m "refactor(worker): switch MCP config to env-var token expansion"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Rewrite `StartAsync` to create the worktree
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (body of `StartAsync` only)
|
||||
|
||||
- [ ] **Step 1: Replace `StartAsync` body (keep signature)**
|
||||
|
||||
Replace the entire method body with:
|
||||
|
||||
```csharp
|
||||
public async Task<PlanningSessionStartContext> StartAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||
await using var _ = ctx;
|
||||
|
||||
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.ParentTaskId is not null)
|
||||
throw new InvalidOperationException("Cannot start a planning session on a child task.");
|
||||
if (task.Status != TaskStatus.Manual)
|
||||
throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
|
||||
|
||||
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||
var listWorkingDir = list.WorkingDir
|
||||
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
|
||||
|
||||
if (!await _git.IsGitRepoAsync(listWorkingDir, ct))
|
||||
throw new InvalidOperationException($"Working directory is not a git repository: {listWorkingDir}");
|
||||
|
||||
var appSettings = await settings.GetAsync(ct);
|
||||
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
||||
var branchName = BranchNameFor(taskId);
|
||||
var baseCommit = await _git.RevParseHeadAsync(listWorkingDir, ct);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!);
|
||||
try
|
||||
{
|
||||
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Self-heal: remove phantom worktrees, prune, delete branch, retry once.
|
||||
var stalePaths = await _git.ListWorktreePathsForBranchAsync(listWorkingDir, branchName, ct);
|
||||
foreach (var stale in stalePaths)
|
||||
{
|
||||
try { await _git.WorktreeRemoveAsync(listWorkingDir, stale, force: true, ct); } catch { }
|
||||
}
|
||||
try { await _git.WorktreePruneAsync(listWorkingDir, ct); } catch { }
|
||||
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
|
||||
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
|
||||
}
|
||||
|
||||
// Write .mcp.json and .claude/settings.local.json into the worktree.
|
||||
var mcpPath = Path.Combine(worktreePath, ".mcp.json");
|
||||
await File.WriteAllTextAsync(mcpPath, BuildMcpConfigJson(), ct);
|
||||
|
||||
var claudeDir = Path.Combine(worktreePath, ".claude");
|
||||
Directory.CreateDirectory(claudeDir);
|
||||
await File.WriteAllTextAsync(Path.Combine(claudeDir, "settings.local.json"), SettingsLocalJson, ct);
|
||||
|
||||
// Session dir + token + prompt files.
|
||||
var token = GenerateToken();
|
||||
var started = await tasks.SetPlanningStartedAsync(taskId, token, ct)
|
||||
?? throw new InvalidOperationException("Failed to transition task to Planning.");
|
||||
|
||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||
Directory.CreateDirectory(sessionDir);
|
||||
|
||||
var files = new PlanningSessionFiles(
|
||||
sessionDir,
|
||||
Path.Combine(sessionDir, "system-prompt.md"),
|
||||
Path.Combine(sessionDir, "initial-prompt.txt"));
|
||||
|
||||
await WriteTokenFileAsync(TokenFilePathFor(sessionDir), token, ct);
|
||||
await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
|
||||
await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);
|
||||
|
||||
return new PlanningSessionStartContext(
|
||||
ParentTaskId: taskId,
|
||||
WorkingDir: worktreePath,
|
||||
Token: token,
|
||||
WorktreePath: worktreePath,
|
||||
BranchName: branchName,
|
||||
Files: files);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
|
||||
git commit -m "feat(worker): create ephemeral worktree and write .mcp.json in StartAsync"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Rewrite `ResumeAsync` and add cleanup to `FinalizeAsync` / `DiscardAsync`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (three methods)
|
||||
|
||||
- [ ] **Step 1: Replace `ResumeAsync` body**
|
||||
|
||||
```csharp
|
||||
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||
await using var _ = ctx;
|
||||
|
||||
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status != TaskStatus.Planning)
|
||||
throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
|
||||
if (string.IsNullOrEmpty(task.PlanningSessionId))
|
||||
throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
|
||||
|
||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||
if (!Directory.Exists(sessionDir))
|
||||
throw new InvalidOperationException($"Session directory missing: {sessionDir}");
|
||||
|
||||
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||
var listWorkingDir = list.WorkingDir
|
||||
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
|
||||
|
||||
var appSettings = await settings.GetAsync(ct);
|
||||
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
||||
if (!Directory.Exists(worktreePath))
|
||||
throw new InvalidOperationException($"Planning worktree missing — cannot resume: {worktreePath}");
|
||||
|
||||
var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct);
|
||||
|
||||
return new PlanningSessionResumeContext(
|
||||
ParentTaskId: taskId,
|
||||
WorkingDir: worktreePath,
|
||||
ClaudeSessionId: task.PlanningSessionId,
|
||||
Token: token,
|
||||
WorktreePath: worktreePath);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend `FinalizeAsync` to clean up worktree + branch**
|
||||
|
||||
Replace the existing `FinalizeAsync` body with:
|
||||
|
||||
```csharp
|
||||
public async Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
||||
{
|
||||
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||
await using var __ = ctx;
|
||||
|
||||
var count = await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
|
||||
|
||||
// Best-effort cleanup — don't block finalization on git state.
|
||||
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||
|
||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||
if (Directory.Exists(sessionDir))
|
||||
{
|
||||
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Extend `DiscardAsync` with the same cleanup**
|
||||
|
||||
Replace the body of `DiscardAsync` with:
|
||||
|
||||
```csharp
|
||||
public async Task DiscardAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||
await using var __ = ctx;
|
||||
|
||||
var ok = await tasks.DiscardPlanningAsync(taskId, ct);
|
||||
|
||||
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||
|
||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||
if (Directory.Exists(sessionDir))
|
||||
{
|
||||
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
if (!ok)
|
||||
throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the `TryCleanupWorktreeAsync` helper**
|
||||
|
||||
Add this private method near the other helpers:
|
||||
|
||||
```csharp
|
||||
private async Task TryCleanupWorktreeAsync(
|
||||
string taskId,
|
||||
ListRepository lists,
|
||||
AppSettingsRepository settings,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (tasks, _, _, ctx2) = CreateRepos();
|
||||
await using var __ = ctx2;
|
||||
|
||||
var task = await tasks.GetByIdAsync(taskId, ct);
|
||||
if (task is null) return;
|
||||
|
||||
var list = await lists.GetByIdAsync(task.ListId, ct);
|
||||
var listWorkingDir = list?.WorkingDir;
|
||||
if (string.IsNullOrEmpty(listWorkingDir) || !Directory.Exists(listWorkingDir)) return;
|
||||
|
||||
var appSettings = await settings.GetAsync(ct);
|
||||
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
||||
var branchName = BranchNameFor(taskId);
|
||||
|
||||
if (Directory.Exists(worktreePath))
|
||||
{
|
||||
try { await _git.WorktreeRemoveAsync(listWorkingDir, worktreePath, force: true, ct); }
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
|
||||
}
|
||||
catch { /* best effort — never block finalize/discard */ }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
|
||||
git commit -m "feat(worker): cleanup planning worktree and branch on finalize/discard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Update `WindowsTerminalPlanningLauncher`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs`
|
||||
|
||||
- [ ] **Step 1: Rewrite `LaunchStartAsync`**
|
||||
|
||||
Replace the full method body with:
|
||||
|
||||
```csharp
|
||||
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(ctx.WorkingDir))
|
||||
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
|
||||
|
||||
if (!File.Exists(ctx.Files.SystemPromptPath))
|
||||
throw new PlanningLaunchException($"System prompt file not found: {ctx.Files.SystemPromptPath}");
|
||||
if (!File.Exists(ctx.Files.InitialPromptPath))
|
||||
throw new PlanningLaunchException($"Initial prompt file not found: {ctx.Files.InitialPromptPath}");
|
||||
|
||||
var resolvedWt = Resolve(_wtPath);
|
||||
if (resolvedWt is null)
|
||||
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
|
||||
|
||||
var resolvedClaude = Resolve(_claudePath);
|
||||
if (resolvedClaude is null)
|
||||
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = resolvedWt,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = false,
|
||||
};
|
||||
|
||||
psi.Environment["MAX_THINKING_TOKENS"] = "20000";
|
||||
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
||||
|
||||
// Arg order: --allowedTools is variadic (space-separated). The positional
|
||||
// prompt must follow a single-value flag, or it will be swallowed.
|
||||
// --append-system-prompt-file serves as that buffer.
|
||||
psi.ArgumentList.Add("-d");
|
||||
psi.ArgumentList.Add(ctx.WorkingDir);
|
||||
psi.ArgumentList.Add(resolvedClaude);
|
||||
psi.ArgumentList.Add("--model");
|
||||
psi.ArgumentList.Add(Model);
|
||||
psi.ArgumentList.Add("--allowedTools");
|
||||
psi.ArgumentList.Add(AllowedTools);
|
||||
psi.ArgumentList.Add("--append-system-prompt-file");
|
||||
psi.ArgumentList.Add(ctx.Files.SystemPromptPath);
|
||||
psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath));
|
||||
|
||||
var proc = Process.Start(psi)
|
||||
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rewrite `LaunchResumeAsync`**
|
||||
|
||||
Replace the full method body with:
|
||||
|
||||
```csharp
|
||||
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(ctx.WorkingDir))
|
||||
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
|
||||
|
||||
var resolvedWt = Resolve(_wtPath);
|
||||
if (resolvedWt is null)
|
||||
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
|
||||
|
||||
var resolvedClaude = Resolve(_claudePath);
|
||||
if (resolvedClaude is null)
|
||||
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = resolvedWt,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = false,
|
||||
};
|
||||
|
||||
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
||||
|
||||
psi.ArgumentList.Add("-d");
|
||||
psi.ArgumentList.Add(ctx.WorkingDir);
|
||||
psi.ArgumentList.Add(resolvedClaude);
|
||||
psi.ArgumentList.Add("--resume");
|
||||
psi.ArgumentList.Add(ctx.ClaudeSessionId);
|
||||
|
||||
var proc = Process.Start(psi)
|
||||
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
|
||||
git commit -m "feat(worker): launcher passes planning token via env, drops --mcp-config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Update DI wiring in `Program.cs`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Program.cs` (around line 59–62)
|
||||
|
||||
- [ ] **Step 1: Update the registration**
|
||||
|
||||
Find:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddSingleton(sp =>
|
||||
new PlanningSessionManager(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
planningSessionsDir));
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddSingleton(sp =>
|
||||
new PlanningSessionManager(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<GitService>(),
|
||||
cfg,
|
||||
planningSessionsDir));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build full worker**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Program.cs
|
||||
git commit -m "chore(worker): wire GitService and WorkerConfig into PlanningSessionManager DI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Fix existing tests (add git init, update constructor calls, drop McpConfigPath assertions)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs`
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs`
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs`
|
||||
|
||||
- [ ] **Step 1: Add a shared git-init helper**
|
||||
|
||||
Create `tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Infrastructure;
|
||||
|
||||
public static class GitRepoFixture
|
||||
{
|
||||
public static void InitRepoWithInitialCommit(string dir)
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
Run(dir, "init", "-b", "main");
|
||||
Run(dir, "config", "user.email", "test@claudedo.local");
|
||||
Run(dir, "config", "user.name", "test");
|
||||
File.WriteAllText(Path.Combine(dir, "README.md"), "seed\n");
|
||||
Run(dir, "add", "-A");
|
||||
Run(dir, "commit", "-m", "chore: seed");
|
||||
}
|
||||
|
||||
private static void Run(string cwd, params string[] args)
|
||||
{
|
||||
var psi = new ProcessStartInfo("git") { WorkingDirectory = cwd, RedirectStandardError = true, RedirectStandardOutput = true };
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
var p = Process.Start(psi)!;
|
||||
p.WaitForExit();
|
||||
if (p.ExitCode != 0)
|
||||
throw new InvalidOperationException($"git {string.Join(" ", args)} failed: {p.StandardError.ReadToEnd()}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `PlanningSessionManagerTests` constructor and seed helper**
|
||||
|
||||
In `PlanningSessionManagerTests.cs`, find the constructor and add after `_rootDir = …;`:
|
||||
|
||||
```csharp
|
||||
_git = new ClaudeDo.Data.Git.GitService();
|
||||
_cfg = new ClaudeDo.Worker.Config.WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") };
|
||||
_settingsRepo = new ClaudeDo.Data.Repositories.AppSettingsRepository(_ctx);
|
||||
// Seed settings row so the manager can read strategy.
|
||||
_settingsRepo.UpsertAsync(new ClaudeDo.Data.Models.AppSettingsEntity { Id = 1, WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
|
||||
_sut = new PlanningSessionManager(_tasks, _lists, _settingsRepo, _git, _cfg, _rootDir);
|
||||
```
|
||||
|
||||
Add three private fields to the class:
|
||||
|
||||
```csharp
|
||||
private readonly ClaudeDo.Data.Git.GitService _git;
|
||||
private readonly ClaudeDo.Worker.Config.WorkerConfig _cfg;
|
||||
private readonly ClaudeDo.Data.Repositories.AppSettingsRepository _settingsRepo;
|
||||
```
|
||||
|
||||
Change `SeedListAsync` to init a git repo:
|
||||
|
||||
```csharp
|
||||
private async Task<(string listId, string workingDir)> SeedListAsync()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
|
||||
ClaudeDo.Worker.Tests.Infrastructure.GitRepoFixture.InitRepoWithInitialCommit(wd);
|
||||
await _lists.AddAsync(new ListEntity
|
||||
{
|
||||
Id = listId,
|
||||
Name = "Test",
|
||||
WorkingDir = wd,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
return (listId, wd);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update assertions in the existing `StartAsync_…` test**
|
||||
|
||||
The old test asserts `ctx.Files.McpConfigPath`. Replace with worktree-based assertions:
|
||||
|
||||
```csharp
|
||||
Assert.Equal(parent.Id, ctx.ParentTaskId);
|
||||
Assert.Equal(ctx.WorktreePath, ctx.WorkingDir);
|
||||
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||
var mcpPath = Path.Combine(ctx.WorktreePath, ".mcp.json");
|
||||
Assert.True(File.Exists(mcpPath));
|
||||
Assert.True(File.Exists(Path.Combine(ctx.WorktreePath, ".claude", "settings.local.json")));
|
||||
Assert.True(File.Exists(ctx.Files.SystemPromptPath));
|
||||
Assert.True(File.Exists(ctx.Files.InitialPromptPath));
|
||||
|
||||
var mcp = await File.ReadAllTextAsync(mcpPath);
|
||||
Assert.Contains("${CLAUDEDO_PLANNING_TOKEN}", mcp);
|
||||
Assert.DoesNotContain(ctx.Token, mcp);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `PlanningEndToEndTests` SUT construction similarly**
|
||||
|
||||
Add the same fields + ctor arguments. Replace any `new PlanningSessionManager(tasks, lists, rootDir)` with `new PlanningSessionManager(tasks, lists, settingsRepo, git, cfg, rootDir)` and ensure the seeded working directory is git-initialized.
|
||||
|
||||
- [ ] **Step 5: Update `WindowsTerminalPlanningLauncherTests`**
|
||||
|
||||
If the existing tests construct `PlanningSessionStartContext` manually, update to supply the new `Token`, `WorktreePath`, `BranchName` fields. Add an assertion that the test observes (via a fake `IPlanningTerminalLauncher`-level check or by verifying the psi after a refactor seam) that the env var is set.
|
||||
|
||||
If the existing launcher test only verifies behavior that's no longer directly testable (it spawns wt.exe), leave those tests as-is but ensure they still compile with the new ctor shape.
|
||||
|
||||
- [ ] **Step 6: Run all planning tests**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~Planning"`
|
||||
Expected: PASS for all tests that previously passed.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/ClaudeDo.Worker.Tests/Planning/ tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs
|
||||
git commit -m "test(worker): adapt planning tests to git-backed worktree flow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: New tests — worktree creation, cleanup, self-heal, resume
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` (append new tests)
|
||||
|
||||
- [ ] **Step 1: Write the failing "worktree is removed on discard" test**
|
||||
|
||||
Append to the test class:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task DiscardAsync_RemovesWorktreeAndBranch()
|
||||
{
|
||||
var (listId, wd) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
|
||||
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||
|
||||
await _sut.DiscardAsync(parent.Id, CancellationToken.None);
|
||||
|
||||
Assert.False(Directory.Exists(ctx.WorktreePath));
|
||||
// branch deleted
|
||||
var paths = await _git.ListWorktreePathsForBranchAsync(wd, ctx.BranchName);
|
||||
Assert.Empty(paths);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect PASS**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "DiscardAsync_RemovesWorktreeAndBranch"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Add "non-git working dir errors" test**
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task StartAsync_ThrowsWhenWorkingDirIsNotGitRepo()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var wd = Path.Combine(Path.GetTempPath(), $"cd_nogit_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(wd);
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "NoGit", WorkingDir = wd, CreatedAt = DateTime.UtcNow });
|
||||
|
||||
var t = await SeedManualTaskAsync(listId);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.StartAsync(t.Id, CancellationToken.None));
|
||||
}
|
||||
```
|
||||
|
||||
Run and expect PASS.
|
||||
|
||||
- [ ] **Step 4: Add self-heal test**
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task StartAsync_SelfHealsWhenBranchAlreadyExists()
|
||||
{
|
||||
var (listId, wd) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
|
||||
// Pre-create a colliding branch.
|
||||
var branch = $"claudedo/planning/{parent.Id.Replace("-", "")}";
|
||||
var head = await _git.RevParseHeadAsync(wd);
|
||||
var procInfo = new System.Diagnostics.ProcessStartInfo("git") { WorkingDirectory = wd };
|
||||
procInfo.ArgumentList.Add("branch");
|
||||
procInfo.ArgumentList.Add(branch);
|
||||
procInfo.ArgumentList.Add(head);
|
||||
var p = System.Diagnostics.Process.Start(procInfo)!;
|
||||
p.WaitForExit();
|
||||
|
||||
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||
}
|
||||
```
|
||||
|
||||
Run and expect PASS.
|
||||
|
||||
- [ ] **Step 5: Add resume test**
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ResumeAsync_ReturnsContextWithTokenAndWorktree()
|
||||
{
|
||||
var (listId, wd) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
|
||||
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
|
||||
// Simulate the claude session capturing its session id.
|
||||
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "session-abc", CancellationToken.None);
|
||||
|
||||
var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(startCtx.Token, resumeCtx.Token);
|
||||
Assert.Equal(startCtx.WorktreePath, resumeCtx.WorktreePath);
|
||||
Assert.Equal("session-abc", resumeCtx.ClaudeSessionId);
|
||||
}
|
||||
```
|
||||
|
||||
Run and expect PASS. If `UpdatePlanningSessionIdAsync` doesn't exist, use whatever repository method captures the Claude session id in this codebase (search the repo for the existing pattern) and substitute; do **not** skip this step.
|
||||
|
||||
- [ ] **Step 6: Run all planning tests**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~Planning"`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
|
||||
git commit -m "test(worker): cover planning worktree lifecycle and self-heal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Manual end-to-end verification
|
||||
|
||||
**Files:** none (manual)
|
||||
|
||||
- [ ] **Step 1: Build all projects**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj && dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Start Worker + UI, create a manual task on a list whose WorkingDir is a real git repo, hit "Start planning"**
|
||||
|
||||
Expected:
|
||||
- A Windows Terminal opens with `claude` running in a worktree under `<parent-of-WorkingDir>\.claudedo-worktrees\planning\<taskId>` (or the central root if strategy=central).
|
||||
- No trust prompt appears for the `claudedo` MCP server.
|
||||
- Inside claude, `/mcp` lists `claudedo` as connected.
|
||||
- Asking claude "create a subtask" invokes `mcp__claudedo__*` tools and the new child task appears in the UI.
|
||||
|
||||
- [ ] **Step 3: Click Discard**
|
||||
|
||||
Expected:
|
||||
- The worktree directory is gone; `git branch --list claudedo/planning/*` returns nothing; `~/.todo-app/sessions/<taskId>` is gone.
|
||||
|
||||
- [ ] **Step 4: Repeat with Finalize** — same expected cleanup.
|
||||
|
||||
- [ ] **Step 5: Close Windows Terminal mid-session, then "Resume"** — same worktree opens again with `--resume`.
|
||||
|
||||
---
|
||||
|
||||
## Deferred / follow-up
|
||||
|
||||
- **Defensive startup cleanup of orphaned planning worktrees.** Enumerate `.claudedo-worktrees/planning/*` (both sibling and central) and GC any whose session dir no longer exists. Ship as a follow-up plan if orphans become a real problem in practice.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** Every section in `docs/superpowers/specs/2026-04-24-planning-worktree-design.md` maps to a task above (data flow → Task 6; launcher → Task 8; cleanup → Task 7; self-heal → Task 6 + Task 11.4; non-git error → Task 11.3; resume → Task 7 + Task 11.5; trust prompt bypass → Task 5 + Task 6). The one spec item deferred is the defensive startup cleanup.
|
||||
- **Placeholder scan:** One conditional in Task 11.5 ("use whatever repository method captures the Claude session id") — this is deliberate: the existing codebase has an accessor whose exact name depends on local conventions and it's faster for the engineer to grep than for me to guess wrong. Every other step has full code.
|
||||
- **Type consistency:** `PlanningSessionStartContext.WorktreePath` and `ResumeContext.WorktreePath` both `string`. `BranchName` only on Start (Resume recomputes via `BranchNameFor`). `Token` on both. `Files.McpConfigPath` removed everywhere.
|
||||
@@ -0,0 +1,897 @@
|
||||
# External MCP — CRUD Extensions 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:** Extend the always-on `ExternalMcpService` with full task CRUD plus tag management so a normal Claude CLI session can fully manage scope-creep tasks via MCP.
|
||||
|
||||
**Architecture:** Pure extension of the existing service. New repository helper for tag replacement; five new/extended `[McpServerTool]` methods. No DI changes (`TagRepository` is already registered for `TaskRepository`/`ListRepository`). Uses the same `X-ClaudeDo-Key` middleware already in place.
|
||||
|
||||
**Tech Stack:** .NET 8, EF Core (SQLite), `ModelContextProtocol.Server` (MCP SDK), xUnit.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight
|
||||
|
||||
The test assembly `tests/ClaudeDo.Worker.Tests` currently fails to compile on `main` because of pre-existing in-progress work on `PlanningChainCoordinator` (stale `TaskRunner` / `WorkerHub` constructor calls in `QueueServiceTests.cs`, `QueueServiceSlotGuardTests.cs`, `PlanningHubTests.cs`). This is unrelated to this work and must NOT be fixed here.
|
||||
|
||||
Consequence: `dotnet test` cannot execute until that refactor lands. Each task's "Run test, verify it fails" step uses `dotnet build` of the **test csproj** to confirm only the new test's compile expectations, and `dotnet build` of the **production csproj** to confirm production code is correct. When the refactor lands, the engineer or user re-runs `dotnet test --filter "FullyQualifiedName~ExternalMcpServiceTests"` to validate the new tests for real.
|
||||
|
||||
Build commands used throughout (per the project memory note "use csproj, not .slnx, on .NET 8"):
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/ClaudeDo.Data/Repositories/TaskRepository.cs` | Add `SetTagsAsync` (replace tag set, auto-create rows) |
|
||||
| `src/ClaudeDo.Worker/External/ExternalMcpService.cs` | Inject `TagRepository`; extend `AddTask` with `tags`; add `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags` |
|
||||
| `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` | New test file with fakes mirroring `Planning/PlanningMcpServiceTests.cs` |
|
||||
|
||||
`TagRepository.GetAllAsync` already exists — no change needed there.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `TaskRepository.SetTagsAsync`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (add new method inside the `#region Tags` block, after `RemoveTagAsync`)
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` (use the existing `MakeTask`/list-seed helpers from that file — match the pattern used in adjacent tests):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task SetTagsAsync_AttachesNewTagsAndCreatesMissingRows()
|
||||
{
|
||||
var listId = await CreateListAsync("L");
|
||||
var task = MakeTask(listId, "t");
|
||||
await _tasks.AddAsync(task);
|
||||
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "novel-tag" });
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
||||
Assert.Contains(tags, t => t.Name == "agent");
|
||||
Assert.Contains(tags, t => t.Name == "novel-tag");
|
||||
Assert.Equal(2, tags.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTagsAsync_ReplacesExistingTagSet()
|
||||
{
|
||||
var listId = await CreateListAsync("L");
|
||||
var task = MakeTask(listId, "t");
|
||||
await _tasks.AddAsync(task);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "manual" });
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
||||
Assert.Single(tags);
|
||||
Assert.Equal("manual", tags[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTagsAsync_DeduplicatesCaseInsensitively()
|
||||
{
|
||||
var listId = await CreateListAsync("L");
|
||||
var task = MakeTask(listId, "t");
|
||||
await _tasks.AddAsync(task);
|
||||
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "AGENT", "Agent" });
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
||||
Assert.Single(tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTagsAsync_EmptyListClearsAllTags()
|
||||
{
|
||||
var listId = await CreateListAsync("L");
|
||||
var task = MakeTask(listId, "t");
|
||||
await _tasks.AddAsync(task);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
|
||||
await _tasks.SetTagsAsync(task.Id, Array.Empty<string>());
|
||||
|
||||
Assert.Empty(await _tasks.GetTagsAsync(task.Id));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
|
||||
```
|
||||
Expected: compile error `CS1061: 'TaskRepository' does not contain a definition for 'SetTagsAsync'`. (Existing unrelated `CS7036` errors from `PlanningChainCoordinator` work also appear — ignore.)
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, inside `#region Tags`, after `RemoveTagAsync`:
|
||||
|
||||
```csharp
|
||||
public async Task SetTagsAsync(string taskId, IReadOnlyList<string> tagNames, CancellationToken ct = default)
|
||||
{
|
||||
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||
if (task is null) return;
|
||||
|
||||
task.Tags.Clear();
|
||||
|
||||
foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
|
||||
if (tag is null)
|
||||
{
|
||||
tag = new TagEntity { Name = name };
|
||||
_context.Tags.Add(tag);
|
||||
}
|
||||
task.Tags.Add(tag);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it compiles + production build still passes**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
||||
```
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "TaskRepositoryTests" || echo "no errors in TaskRepositoryTests"
|
||||
```
|
||||
Expected: no errors specific to `TaskRepositoryTests` (assembly may still fail due to unrelated `PlanningChainCoordinator` issues).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs
|
||||
git commit -m "feat(data): add TaskRepository.SetTagsAsync for full tag-set replacement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: New test file scaffolding for `ExternalMcpService`
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||
|
||||
This task creates the shared test fakes and one trivial passing test. Subsequent tasks reuse the same fakes.
|
||||
|
||||
- [ ] **Step 1: Inspect existing patterns**
|
||||
|
||||
Read `tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs` for the `FakeHubContext`/`RecordingClientProxy` pattern and `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs` for how to construct a real `QueueService` for tests (the same approach is used here — `ExternalMcpService` depends on it for `WakeQueue`/`RunNow`/`CancelTask`).
|
||||
|
||||
- [ ] **Step 2: Write the test scaffolding**
|
||||
|
||||
Create `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Services;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
file sealed class RecordingHubClients : IHubClients
|
||||
{
|
||||
public RecordingClientProxy Proxy { get; } = new();
|
||||
public IClientProxy All => Proxy;
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||
public IClientProxy Client(string connectionId) => Proxy;
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
|
||||
public IClientProxy Group(string groupName) => Proxy;
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
|
||||
public IClientProxy User(string userId) => Proxy;
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
|
||||
}
|
||||
|
||||
file sealed class RecordingClientProxy : IClientProxy
|
||||
{
|
||||
public List<(string Method, object?[] Args)> Calls { get; } = new();
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Calls.Add((method, args));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeHubContext : IHubContext<WorkerHub>
|
||||
{
|
||||
public RecordingHubClients RecordingClients { get; } = new();
|
||||
public IHubClients Clients => RecordingClients;
|
||||
public IGroupManager Groups => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public sealed class ExternalMcpServiceTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TagRepository _tags;
|
||||
private readonly FakeHubContext _hub;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
|
||||
public ExternalMcpServiceTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
_tags = new TagRepository(_ctx);
|
||||
_hub = new FakeHubContext();
|
||||
_broadcaster = new HubBroadcaster(_hub);
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private async Task<string> SeedListAsync(string name = "L")
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow });
|
||||
return id;
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Manual)
|
||||
{
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = title,
|
||||
Status = status,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
// QueueService is needed by ExternalMcpService's constructor. For tests that
|
||||
// only exercise UpdateTask / DeleteTask / SetTaskTags / ListTags / ListTags,
|
||||
// we never call its WakeQueue/RunNow/CancelTask paths, so a real QueueService
|
||||
// built with the same approach used in QueueServiceTests is sufficient.
|
||||
private ExternalMcpService BuildSut(QueueService queue) =>
|
||||
new(_tasks, _lists, queue, _broadcaster, _tags);
|
||||
|
||||
[Fact]
|
||||
public async Task SeededListAndTask_AreRetrievable()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
Assert.NotNull(await _tasks.GetByIdAsync(task.Id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The trivial `SeededListAndTask_AreRetrievable` test exists to confirm the scaffolding compiles and the fakes work, without depending on `ExternalMcpService` itself yet.
|
||||
|
||||
Note: `BuildSut` uses a 5-argument constructor signature that does not exist yet — this matches the future signature added in Task 3. The compiler will accept this method only after Task 3.
|
||||
|
||||
- [ ] **Step 3: Verify the file references resolve**
|
||||
|
||||
Build the test csproj and check for errors specific to `ExternalMcpServiceTests`:
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "ExternalMcpServiceTests"
|
||||
```
|
||||
Expected output: only one error referring to the 5-arg `ExternalMcpService` constructor (resolved in Task 3). No missing-namespace or syntax errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||
git commit -m "test(external): scaffold ExternalMcpServiceTests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Inject `TagRepository` into `ExternalMcpService` + add `ListTags`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||
|
||||
Smallest possible change to unblock everything else: take the new dependency and ship the simplest tool first.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`. The `BuildSut` helper is defined in Task 2; tests construct `QueueService` the same way `QueueServiceTests.cs` does (look there for the exact constructor argument list and adopt it verbatim):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ListTags_ReturnsSeededAndCustomTags()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "custom-tag" });
|
||||
|
||||
using var queue = QueueServiceFactory.Create(_ctx, _broadcaster); // see helper note below
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var tags = await sut.ListTags(CancellationToken.None);
|
||||
|
||||
Assert.Contains(tags, t => t.Name == "agent");
|
||||
Assert.Contains(tags, t => t.Name == "custom-tag");
|
||||
}
|
||||
```
|
||||
|
||||
If a `QueueServiceFactory` helper does not already exist in the test project, inline the construction by mirroring the setup found in `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs` (it builds `QueueService` directly with `IDbContextFactory`, `HubBroadcaster`, fake claude process, etc.). Do NOT call `StartAsync`; just construct and dispose.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep -E "ExternalMcpService|ExternalMcpServiceTests"
|
||||
```
|
||||
Expected: errors about the 5-arg constructor and `ListTags` not existing.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `src/ClaudeDo.Worker/External/ExternalMcpService.cs`:
|
||||
|
||||
1. Add `TagRepository` field and constructor parameter:
|
||||
|
||||
```csharp
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly QueueService _queue;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly TagRepository _tags;
|
||||
|
||||
public ExternalMcpService(
|
||||
TaskRepository tasks,
|
||||
ListRepository lists,
|
||||
QueueService queue,
|
||||
HubBroadcaster broadcaster,
|
||||
TagRepository tags)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_lists = lists;
|
||||
_queue = queue;
|
||||
_broadcaster = broadcaster;
|
||||
_tags = tags;
|
||||
}
|
||||
```
|
||||
|
||||
2. Add a tag DTO above the class (next to `TaskListDto`):
|
||||
|
||||
```csharp
|
||||
public sealed record TagDto(long Id, string Name);
|
||||
```
|
||||
|
||||
3. Add the new tool method (place at the end of the class, before `ToDto`):
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("List all known tags. Useful for discovering existing tag names (including 'agent' which marks tasks for auto-execution) before tagging.")]
|
||||
public async Task<IReadOnlyList<TagDto>> ListTags(CancellationToken cancellationToken)
|
||||
{
|
||||
var tags = await _tags.GetAllAsync(cancellationToken);
|
||||
return tags.Select(t => new TagDto(t.Id, t.Name)).ToList();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify production build + new test compiles**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
```
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "ExternalMcpService"
|
||||
```
|
||||
Expected: no errors mentioning `ExternalMcpService` or `ListTags`. (Unrelated `PlanningChainCoordinator` errors persist.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||
git commit -m "feat(mcp/external): add ListTags + inject TagRepository"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Extend `AddTask` to accept `tags`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs` (`AddTask` method)
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `ExternalMcpServiceTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task AddTask_WithTags_AttachesTags()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
using var queue = /* same construction as ListTags test */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.AddTask(
|
||||
listId, "scope-creep handoff", "desc", "claude-cli",
|
||||
queueImmediately: false,
|
||||
tags: new[] { "agent", "custom" },
|
||||
CancellationToken.None);
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(dto.Id);
|
||||
Assert.Contains(tags, t => t.Name == "agent");
|
||||
Assert.Contains(tags, t => t.Name == "custom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTask_NullTags_BehavesAsBefore()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.AddTask(
|
||||
listId, "no tags", null, "claude-cli",
|
||||
queueImmediately: false, tags: null, CancellationToken.None);
|
||||
|
||||
Assert.Empty(await _tasks.GetTagsAsync(dto.Id));
|
||||
}
|
||||
```
|
||||
|
||||
(Replace the `/* same construction */` placeholder with the actual `QueueService` construction used in Task 3 — repeat the code, do not extract.)
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "AddTask"
|
||||
```
|
||||
Expected: error that `AddTask` does not accept a 7th `tags` parameter.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Replace the existing `AddTask` method in `ExternalMcpService.cs` with:
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. Optional tags are attached on creation; missing tag names auto-create.")]
|
||||
public async Task<TaskDto> AddTask(
|
||||
string listId,
|
||||
string title,
|
||||
string? description,
|
||||
string createdBy,
|
||||
bool queueImmediately,
|
||||
IReadOnlyList<string>? tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(listId))
|
||||
throw new InvalidOperationException("listId is required.");
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
throw new InvalidOperationException("title is required.");
|
||||
if (string.IsNullOrWhiteSpace(createdBy))
|
||||
throw new InvalidOperationException("createdBy is required.");
|
||||
|
||||
var list = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
|
||||
var entity = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = title,
|
||||
Description = description,
|
||||
Status = queueImmediately ? TaskStatus.Queued : TaskStatus.Manual,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = list.DefaultCommitType,
|
||||
CreatedBy = createdBy,
|
||||
};
|
||||
await _tasks.AddAsync(entity, cancellationToken);
|
||||
|
||||
if (tags is not null && tags.Count > 0)
|
||||
await _tasks.SetTagsAsync(entity.Id, tags, cancellationToken);
|
||||
|
||||
if (queueImmediately)
|
||||
_queue.WakeQueue();
|
||||
|
||||
await _broadcaster.TaskUpdated(entity.Id);
|
||||
return ToDto(entity);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify production build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
```
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||
git commit -m "feat(mcp/external): AddTask accepts tags on creation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `UpdateTask`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `ExternalMcpServiceTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task UpdateTask_PatchesNonNullFieldsOnly()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, "old title");
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("new title", dto.Title);
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal("new title", loaded!.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_TagsReplaceFullSet()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await sut.UpdateTask(task.Id, null, null, null, new[] { "manual" }, CancellationToken.None);
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
||||
Assert.Single(tags);
|
||||
Assert.Equal("manual", tags[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_OnRunning_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_NotFound_Throws()
|
||||
{
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "UpdateTask"
|
||||
```
|
||||
Expected: errors that `UpdateTask` does not exist on `ExternalMcpService`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Add to `ExternalMcpService.cs` (after `AddTask`):
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("Update an existing task's title, description, commit type, and/or tags. Pass null to leave a field unchanged. Tags are replaced as a full set when non-null. Refuses if the task is currently Running.")]
|
||||
public async Task<TaskDto> UpdateTask(
|
||||
string taskId,
|
||||
string? title,
|
||||
string? description,
|
||||
string? commitType,
|
||||
IReadOnlyList<string>? tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status == TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot update a running task. Cancel it first.");
|
||||
|
||||
if (title is not null) task.Title = title;
|
||||
if (description is not null) task.Description = description;
|
||||
if (commitType is not null) task.CommitType = commitType;
|
||||
await _tasks.UpdateAsync(task, cancellationToken);
|
||||
|
||||
if (tags is not null)
|
||||
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
|
||||
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return ToDto(reload);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify production build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
```
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||
git commit -m "feat(mcp/external): add UpdateTask for content/tag patching"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: `DeleteTask`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `ExternalMcpServiceTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task DeleteTask_RemovesTaskAndTagJoins()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await sut.DeleteTask(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Null(await _tasks.GetByIdAsync(task.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTask_OnRunning_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.DeleteTask(task.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTask_NotFound_Throws()
|
||||
{
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.DeleteTask("does-not-exist", CancellationToken.None));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "DeleteTask"
|
||||
```
|
||||
Expected: errors that `DeleteTask` does not exist on `ExternalMcpService`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Add to `ExternalMcpService.cs` (after `UpdateTask`):
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("Delete a task. Refuses if the task is currently Running — cancel it first.")]
|
||||
public async Task DeleteTask(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status == TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");
|
||||
|
||||
await _tasks.DeleteAsync(taskId, cancellationToken);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify production build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
```
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||
git commit -m "feat(mcp/external): add DeleteTask"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: `SetTaskTags`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `ExternalMcpServiceTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task SetTaskTags_ReplacesTagSetAndBroadcasts()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None);
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
||||
Assert.Single(tags);
|
||||
Assert.Equal("manual", tags[0].Name);
|
||||
Assert.Contains(_hub.RecordingClients.Proxy.Calls,
|
||||
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == task.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTaskTags_OnRunning_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "SetTaskTags"
|
||||
```
|
||||
Expected: errors that `SetTaskTags` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Add to `ExternalMcpService.cs` (after `DeleteTask`):
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("Replace the full tag set on an existing task. Missing tag names auto-create. Refuses if the task is Running.")]
|
||||
public async Task<TaskDto> SetTaskTags(
|
||||
string taskId,
|
||||
IReadOnlyList<string> tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status == TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot retag a running task. Cancel it first.");
|
||||
|
||||
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return ToDto(reload);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify production build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
```
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||
git commit -m "feat(mcp/external): add SetTaskTags"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final verification + docs touch
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/CLAUDE.md` (one-line update reflecting the new tools)
|
||||
|
||||
- [ ] **Step 1: Full production build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
||||
```
|
||||
Expected: both succeed with 0 errors.
|
||||
|
||||
- [ ] **Step 2: Update Worker CLAUDE.md**
|
||||
|
||||
In `src/ClaudeDo.Worker/CLAUDE.md`, locate the existing line near the bottom of the file describing external MCP tools (search for `ExternalMcpService` or `External/`). If a list of tools is already there, append the new tool names: `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags`. If no such line exists, add one short line under an existing structural section, for example under "Architecture":
|
||||
|
||||
```markdown
|
||||
- **External/ExternalMcpService** — always-on MCP tools for general Claude sessions: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask` (with tags), `UpdateTask`, `UpdateTaskStatus`, `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`. Auth via optional `X-ClaudeDo-Key` header.
|
||||
```
|
||||
|
||||
If the file already has a similar line — replace it; do not duplicate.
|
||||
|
||||
- [ ] **Step 3: Verify the full test assembly state is unchanged**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep -E "error CS" | grep -v "PlanningChainCoordinator\|TaskRunner.*chain\|WorkerHub.*planningChain"
|
||||
```
|
||||
Expected: empty output (every remaining error must be one of the pre-existing `PlanningChainCoordinator`-related errors and nothing new).
|
||||
|
||||
- [ ] **Step 4: When the unrelated refactor lands, run the new tests**
|
||||
|
||||
(Defer to whoever lands the `PlanningChainCoordinator` refactor — they should run:)
|
||||
|
||||
```bash
|
||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~ExternalMcpServiceTests|FullyQualifiedName~TaskRepositoryTests.SetTagsAsync"
|
||||
```
|
||||
Expected: all new tests green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/CLAUDE.md
|
||||
git commit -m "docs(worker): document new external MCP tools"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
- `AddTask` extension with tags → Task 4 ✓
|
||||
- `UpdateTask` → Task 5 ✓
|
||||
- `DeleteTask` → Task 6 ✓
|
||||
- `SetTaskTags` → Task 7 ✓
|
||||
- `ListTags` → Task 3 ✓
|
||||
- `TaskRepository.SetTagsAsync` → Task 1 ✓
|
||||
- Auth (no change) → out of scope, called out in pre-flight ✓
|
||||
- Tests for each tool → Tasks 1, 3-7 ✓
|
||||
- Docs touch → Task 8 ✓
|
||||
|
||||
**Placeholder scan:** The phrase `/* same construction */` in tasks 4–7 is intentional — the engineer fills it in by mirroring the `QueueService` construction in Task 3 (which itself mirrors `QueueServiceTests.cs`). All other placeholders eliminated. No "TBD".
|
||||
|
||||
**Type consistency:**
|
||||
- `IReadOnlyList<string>` for tag inputs everywhere ✓
|
||||
- `TaskDto` returned by `AddTask`, `UpdateTask`, `SetTaskTags` ✓
|
||||
- `TagDto(long Id, string Name)` consistent across `ListTags` ✓
|
||||
- Constructor signature `(TaskRepository, ListRepository, QueueService, HubBroadcaster, TagRepository)` consistent between Task 3 implementation and Task 2 scaffold's `BuildSut` call ✓
|
||||
- Method `TaskRepository.SetTagsAsync(string, IReadOnlyList<string>, CancellationToken)` consistent with all callers ✓
|
||||
|
||||
No issues found.
|
||||
@@ -0,0 +1,225 @@
|
||||
# Session Prompts — Worker State & Queue Consolidation Slices 2–6
|
||||
|
||||
Paste-ready prompts for each remaining slice. Run **one slice per session** so the diff stays reviewable and tests stay green between commits. Spec lives at `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` — reference it when the prompt asks.
|
||||
|
||||
**Common ground rules** (carry across all slices):
|
||||
|
||||
- Direct on `main`, one commit per slice, conventional commit messages.
|
||||
- Build green (`dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` + Data + Ui) before commit.
|
||||
- Pre-existing test errors (TaskRunner/WorkerHub constructor drift in 4 test files) are **not** in scope to fix — they exist on `main` already. New compile errors my changes introduce ARE in scope.
|
||||
- No drive-by refactors outside the slice's stated scope.
|
||||
- New files must follow existing naming/folder conventions; legacy enum values stay until Slice 6.
|
||||
- After each slice, update `~/.claude/projects/C--Private-ClaudeDo/memory/` if I learn something durable about the codebase.
|
||||
|
||||
---
|
||||
|
||||
## Slice 2 — `TaskStateService` (centralized state machine)
|
||||
|
||||
**Prompt to paste into a fresh session:**
|
||||
|
||||
> Slice 2 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (sections 2 and 8). Slice 1 already landed (commit 7b737e6) — `TaskStatus` has `Idle`/`Cancelled`, `PlanningPhase` enum exists, `BlockedByTaskId` field exists. Legacy enum values still around.
|
||||
>
|
||||
> **Goal:** introduce `Worker/State/ITaskStateService` + `TaskStateService` as the single component that mutates `Status`, `PlanningPhase`, `BlockedByTaskId`. Migrate every existing caller. Mark repo `Mark*Async` helpers `internal`.
|
||||
>
|
||||
> **Public surface (verbatim from spec):**
|
||||
> ```csharp
|
||||
> Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct);
|
||||
> Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct);
|
||||
> Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
|
||||
> Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
|
||||
> Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
|
||||
> Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
|
||||
> Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct);
|
||||
> Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct);
|
||||
> Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
|
||||
> Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
|
||||
> Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct);
|
||||
> ```
|
||||
>
|
||||
> **Allowed transition table:** see spec §2. Reject invalid transitions with `TransitionResult(false, "<reason>")` — no exceptions. Each transition is one atomic `ExecuteUpdate` with `WHERE Status = <expected>` for TOCTOU-freedom.
|
||||
>
|
||||
> **Side effects after successful DB write** (do these inside the service so callers don't need to remember):
|
||||
> - On any `→ Queued`: call `_queue.WakeQueue()` directly for now (Slice 3 will replace with `IQueueWaker`). Inject `QueueService` lazily via `Func<QueueService>` to break the DI cycle if needed.
|
||||
> - On any successful transition: `_broadcaster.TaskUpdated(taskId)`.
|
||||
> - On `Done`/`Failed`/`Cancelled` for a child task: invoke `_chain.OnChildFinishedAsync(taskId, finalStatus, ct)`. If it returns a next-task-id, call `UnblockAsync` on it. Then run `_repo.TryCompleteParentAsync(parentId, ct)`.
|
||||
>
|
||||
> **Important:** `BlockOnAsync` and `UnblockAsync` should write `BlockedByTaskId` directly. `EnqueueAsync` for a Planning child should keep `BlockedByTaskId` null when it's the head of the chain. The chain coordinator will compose these calls in Slice 4 — for now just expose the API.
|
||||
>
|
||||
> **Caller migration (mechanical — preserve current behavior):**
|
||||
> - `TaskRunner.HandleSuccess` → replace `taskRepo.MarkDoneAsync` + `TryCompleteParentAsync` + `_chain.OnChildFinishedAsync` block with a single `_state.CompleteAsync(taskId, finishedAt, result, CancellationToken.None)`.
|
||||
> - `TaskRunner.HandleFailure` → `_state.FailAsync(taskId, finishedAt, errorMarkdown, CancellationToken.None)`.
|
||||
> - `TaskRunner.MarkFailed` (early-fail path) → same.
|
||||
> - `TaskRunner.RunAsync` start of run → `_state.StartRunningAsync(taskId, startedAt, ct)`.
|
||||
> - `StaleTaskRecovery.StartAsync` → `_state.RecoverStaleRunningAsync("worker restart", ct)`.
|
||||
> - `TaskResetService.ResetAsync` → `_state.ResetToIdleAsync(taskId, ct)` for the status flip; service keeps owning worktree cleanup.
|
||||
> - `PlanningSessionManager.StartAsync` (the `SetPlanningStartedAsync` call) → `_state.StartPlanningAsync(parentId, ct)`. The manager still owns token/session-dir setup; only the status flip moves.
|
||||
> - `PlanningChainCoordinator.OnChildFinishedAsync` (the `next.Status = TaskStatus.Queued` write) → keep its existing logic but use `_state.UnblockAsync(next.Id, ct)` for the actual write. The Slice 4 rewrite finishes the rest.
|
||||
> - `ExternalMcpService.UpdateTaskStatus` (status flip in the Queued case) → `_state.EnqueueAsync(taskId, ct)`. The Manual case stays as-is until Slice 6 since `Manual` is still a valid legacy value.
|
||||
>
|
||||
> **Repo helpers to mark `internal`:** `MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`, `FlipAllRunningToFailedAsync`. Verify nothing outside `ClaudeDo.Worker.State` calls them after migration. (`Worker.Tests` may need `InternalsVisibleTo` — add it if so.)
|
||||
>
|
||||
> **DI wiring:** register `TaskStateService` as Singleton in `Program.cs` for both the main app and the external-MCP app. The service holds no per-request state.
|
||||
>
|
||||
> **Tests:** new file `tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs`. At minimum:
|
||||
> - Happy path for each transition (verify DB state + side-effect mocks invoked).
|
||||
> - Reject path for each invalid transition (verify result + DB unchanged).
|
||||
> - Concurrency: two parallel `StartRunningAsync` for the same `Queued` task → exactly one returns `Ok=true`.
|
||||
> - Mock or fake the broadcaster, queue, and chain-coordinator dependencies. Use real SQLite for the DB (existing test pattern).
|
||||
>
|
||||
> Build all projects, run the worker test project (the 4 pre-existing constructor-drift errors are out of scope — but my changes shouldn't add new errors), commit as `refactor(worker/state): introduce TaskStateService and route mutations through it`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 3 — `IQueueWaker` + `IQueuePicker`
|
||||
|
||||
**Prompt to paste into a fresh session:**
|
||||
|
||||
> Slice 3 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 3). Slices 1 and 2 already landed.
|
||||
>
|
||||
> **Goal:** extract queue-wake and queue-pick from `QueueService` and `TaskRepository` into dedicated single-responsibility components. Make wakes automatic.
|
||||
>
|
||||
> **New components in `Worker/Queue/`:**
|
||||
> - `IQueueWaker` (interface, `void Wake()`). Backed by `QueueWaker` singleton holding the existing `SemaphoreSlim`. Inject into `TaskStateService` (replaces the direct `QueueService` ref from Slice 2) and into `QueueService` itself.
|
||||
> - `IQueuePicker` with `Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct)`. Implementation `QueuePicker` moves the raw SQL out of `TaskRepository.GetNextQueuedAgentTaskAsync` and **adds a `blocked_by_task_id IS NULL` filter to the WHERE clause**. Order stays `sort_order ASC, created_at ASC` (verify the existing query — add ORDER BY if missing). Atomic `UPDATE … RETURNING` flips `Queued → Running` and writes `started_at`.
|
||||
>
|
||||
> **Caller updates:**
|
||||
> - `TaskStateService` swaps its `Func<QueueService>` for `IQueueWaker`. The `→ Queued` side-effect now calls `_waker.Wake()`.
|
||||
> - `QueueService.ExecuteAsync` calls `_picker.ClaimNextAsync` instead of `_taskRepo.GetNextQueuedAgentTaskAsync`. The slot-claim, broadcaster, and `WakeQueue()` after slot release stay where they are.
|
||||
> - `WorkerHub.WakeQueue()` and `ExternalMcpService.WakeQueue` calls in app code → remove the explicit invocations. The state-service triggers waking automatically. **Keep** the SignalR/MCP endpoint that exposes `WakeQueue()` for diagnostics/manual use — that one delegates to `_waker.Wake()`.
|
||||
> - `TaskRepository.GetNextQueuedAgentTaskAsync` becomes a thin shim that forwards to `IQueuePicker` for any remaining tests, OR delete it and update tests to use the picker. Prefer delete if tests are easy to migrate.
|
||||
>
|
||||
> **Tests:** new `tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs`:
|
||||
> - Skipped: `BlockedByTaskId` set; missing agent tag; `scheduled_for > now`; status not Queued.
|
||||
> - Picked: correct order (`sort_order, created_at`).
|
||||
> - Atomic claim: two parallel pickers → exactly one row returned non-null, the other null.
|
||||
>
|
||||
> Update existing `TaskRepositoryTests.GetNextQueuedAgentTaskAsync_*` tests if they exercised the removed method.
|
||||
>
|
||||
> Build, test, commit as `refactor(worker/queue): split queue waker and picker, auto-wake on enqueue`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 4 — Planning flow consolidation (kills the original bug)
|
||||
|
||||
**Prompt to paste into a fresh session:**
|
||||
|
||||
> Slice 4 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 4). Slices 1–3 already landed. **This slice eliminates the original "queue never picks up planning tasks" bug structurally.**
|
||||
>
|
||||
> **Goal:** one path through planning. Delete the dual-flow problem.
|
||||
>
|
||||
> **Changes:**
|
||||
> - **Delete** `TaskRepository.FinalizePlanningAsync` entirely. Also delete its tests in `TaskRepositoryPlanningTests.cs`.
|
||||
> - **Rewrite** `PlanningSessionManager.FinalizeAsync(taskId, queueAgentTasks, ct)`:
|
||||
> 1. `_state.FinalizePlanningAsync(parentId, ct)` (sets parent `PlanningPhase=Finalized`, `Status=Idle`).
|
||||
> 2. If `queueAgentTasks` is true, call the new `_chainCoordinator.SetupChainAsync(parentId, ct)`.
|
||||
> 3. Existing worktree-cleanup + session-dir-deletion remains.
|
||||
> 4. Return the count of children that ended up in the chain.
|
||||
> - **Rename** `PlanningChainCoordinator.QueueSubtasksSequentiallyAsync` → `SetupChainAsync`. Make it `internal`. New behavior:
|
||||
> - Eligibility check: children must be in `Status=Idle` (was `Manual` or `Planned` legacy values — keep tolerating those for one slice via OR).
|
||||
> - Auto-attach `agent` tag to all children (already in WIP — keep that behavior).
|
||||
> - For first child: `_state.EnqueueAsync(child[0].Id, ct)` (no BlockedBy, head of chain).
|
||||
> - For rest: `_state.EnqueueAsync(child[i].Id, ct)` followed immediately by `_state.BlockOnAsync(child[i].Id, child[i-1].Id, ct)`. (Or: add a single `EnqueueBlockedAsync` helper to TaskStateService if call-site clutter bothers you.)
|
||||
> - **Update** `PlanningChainCoordinator.OnChildFinishedAsync`: replace status-via-LINQ logic with: query for the next child where `BlockedByTaskId == childTaskId`, call `_state.UnblockAsync` on it. Drop the `Waiting` lookup entirely.
|
||||
> - Audit `Status == TaskStatus.Waiting` in UI/tests — replace with `Status == Queued && BlockedByTaskId != null`. (UI changes confirmed against `TaskRowViewModel`, `TasksIslandViewModel` from Slice 1's WIP.)
|
||||
>
|
||||
> **Regression test:** new `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs` (or extend existing) — `Active` parent + 3 drafts → call `FinalizeAsync(queueAgentTasks: true)` → assert within 200 ms the first child has `Status=Running` (queue picker claimed it) without anyone calling `WakeQueue()` manually. This was the bug the user originally reported.
|
||||
>
|
||||
> **Update** `PlanningMcpService.EditableStatuses` — replace `Waiting` with `Queued` (since blocked tasks are now `Queued + BlockedByTaskId`). Verify the MCP tool still gates on `parent.PlanningPhase == Active` (legacy: `parent.Status == Planning`).
|
||||
>
|
||||
> Build, test, commit as `feat(planning): consolidate finalize+chain via TaskStateService, fix queue pickup`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 5 — `OverrideSlotService` + folder reorg
|
||||
|
||||
**Prompt to paste into a fresh session:**
|
||||
|
||||
> Slice 5 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 5). Slices 1–4 already landed.
|
||||
>
|
||||
> **Goal:** split the override slot out of QueueService and reorganize `Worker/Services/` into domain folders.
|
||||
>
|
||||
> **`OverrideSlotService` (new in `Worker/Queue/`):**
|
||||
> - Owns the `_overrideSlot` field, `RunNow(taskId)`, `ContinueTask(taskId, followUpPrompt)`, and the override-slot piece of `CancelTask`.
|
||||
> - Status mutations go through `TaskStateService.StartRunningAsync` (non-atomic claim is fine; serialized by slot lock).
|
||||
> - `QueueService.CancelTask` delegates to `OverrideSlotService.TryCancel` first, falls back to its own queue slot.
|
||||
> - WorkerHub's `RunNow`/`ContinueTask`/`CancelTask` SignalR endpoints route to the new service via `OverrideSlotService` when applicable; keep the signatures stable.
|
||||
>
|
||||
> **Folder reorg** (use `git mv`, don't copy/delete):
|
||||
> ```
|
||||
> Worker/State/ ← ITaskStateService.cs, TaskStateService.cs, TransitionResult.cs (already exist; no move needed if already there)
|
||||
> Worker/Queue/ ← IQueueWaker.cs, QueueWaker.cs, IQueuePicker.cs, QueuePicker.cs, QueueService.cs, OverrideSlotService.cs, QueueSlotState.cs
|
||||
> Worker/Lifecycle/ ← StaleTaskRecovery.cs, TaskResetService.cs, TaskMergeService.cs
|
||||
> Worker/Worktrees/ ← WorktreeMaintenanceService.cs
|
||||
> Worker/Agents/ ← AgentFileService.cs, DefaultAgentSeeder.cs
|
||||
> Worker/Runner/ ← unchanged
|
||||
> Worker/Planning/ ← unchanged
|
||||
> Worker/External/ ← unchanged
|
||||
> Worker/Hub/ ← unchanged
|
||||
> ```
|
||||
>
|
||||
> Update namespaces to match folders (existing convention: namespace == folder path under `ClaudeDo.Worker`). Delete the old `Worker/Services/` folder once empty.
|
||||
>
|
||||
> Update DI registrations in `Program.cs` (both apps) — most calls just need `using` updates. `OverrideSlotService` is a new singleton.
|
||||
>
|
||||
> Update test `using` statements to follow.
|
||||
>
|
||||
> Build, test, commit as `refactor(worker): extract OverrideSlotService and reorganize Worker/Services into domain folders`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 6 — Cleanup, legacy retirement, docs
|
||||
|
||||
**Prompt to paste into a fresh session:**
|
||||
|
||||
> Slice 6 (final) of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 6 + slice plan). Slices 1–5 already landed.
|
||||
>
|
||||
> **Goal:** retire legacy enum values, backfill DB rows, update docs.
|
||||
>
|
||||
> **EF migration `RetireLegacyTaskStatus`:**
|
||||
> ```sql
|
||||
> UPDATE tasks SET status='idle' WHERE status IN ('manual', 'draft');
|
||||
> UPDATE tasks SET status='idle', planning_phase='active' WHERE status='planning';
|
||||
> UPDATE tasks SET status='idle', planning_phase='finalized' WHERE status='planned';
|
||||
>
|
||||
> -- Waiting → Queued + blocked_by from sort_order:
|
||||
> WITH ordered AS (
|
||||
> SELECT id,
|
||||
> LAG(id) OVER (PARTITION BY parent_task_id ORDER BY sort_order, created_at) AS prev_id
|
||||
> FROM tasks WHERE status='waiting'
|
||||
> )
|
||||
> UPDATE tasks
|
||||
> SET status='queued',
|
||||
> blocked_by_task_id=(SELECT prev_id FROM ordered WHERE ordered.id=tasks.id)
|
||||
> WHERE id IN (SELECT id FROM ordered);
|
||||
> ```
|
||||
> Use `migrationBuilder.Sql(...)` for these. Down() is best-effort: `Cancelled` → `Failed`, `(idle, finalized)` → `planned`, `(idle, active)` → `planning`, `queued + blocked_by_task_id != null` → `waiting`. Document lossiness in a comment.
|
||||
>
|
||||
> **Code changes:**
|
||||
> - Remove legacy values from `TaskStatus` enum: `Manual, Planning, Planned, Draft, Waiting`.
|
||||
> - Strip the legacy branches from `TaskEntityConfiguration.StatusToString`/`StatusFromString`.
|
||||
> - Default for `TaskEntity.Status` is `TaskStatus.Idle` (already correct after Slice 1's revert).
|
||||
> - Audit + remap every remaining caller — they should already use new values from Slices 2–4, but search for any leftover `TaskStatus.Manual` etc. in:
|
||||
> - tests (~10 files seed status — flip to `Idle`/`Queued`/etc.)
|
||||
> - UI (`TaskRowViewModel.IsPlanningParent`, `IsDraft`, `CanOpenPlanningSession`, status maps — replace with `PlanningPhase` checks where appropriate)
|
||||
> - any leftover guards in MCP/services
|
||||
> - Mark `Mark*Async` repo helpers as `internal` if not already (Slice 2 should have done this — verify).
|
||||
>
|
||||
> **Docs to update:**
|
||||
> - `src/ClaudeDo.Worker/CLAUDE.md` — new folder structure, new state-service flow, new wake mechanics, removal of legacy values.
|
||||
> - `src/ClaudeDo.Data/CLAUDE.md` — TaskEntity new fields (`PlanningPhase`, `BlockedByTaskId`), retired legacy enum values, new tag-attach behavior.
|
||||
> - `docs/plan.md` — update status flow section.
|
||||
> - `docs/open.md` — close the "queue doesn't pick up planning tasks" item if it's tracked there; add any follow-ups discovered along the way.
|
||||
> - Memory: update `~/.claude/projects/C--Private-ClaudeDo/memory/` with a new entry summarizing the new architecture (state-service + queue split + planning chain via blocked-by).
|
||||
>
|
||||
> **Sanity tests** — full test run. The 4 pre-existing constructor-drift errors should still be the only failures. If new ones surfaced from missed legacy-value remappings, fix them before commit.
|
||||
>
|
||||
> Build, full test run, commit as `refactor(data): retire legacy TaskStatus values and backfill existing rows`.
|
||||
|
||||
---
|
||||
|
||||
## After Slice 6
|
||||
|
||||
- All 6 slices on `main`.
|
||||
- The original bug ("queue doesn't pick up planning tasks") is structurally impossible.
|
||||
- Worker has clear domain folders, single state-mutator, single queue-picker.
|
||||
- Spec doc + this prompt file can be deleted or moved to `docs/superpowers/done/`.
|
||||
2424
docs/superpowers/plans/2026-04-28-tabbed-settings-prime-claude.md
Normal file
2424
docs/superpowers/plans/2026-04-28-tabbed-settings-prime-claude.md
Normal file
File diff suppressed because it is too large
Load Diff
1423
docs/superpowers/plans/2026-05-19-worktree-overview-modal.md
Normal file
1423
docs/superpowers/plans/2026-05-19-worktree-overview-modal.md
Normal file
File diff suppressed because it is too large
Load Diff
834
docs/superpowers/plans/2026-05-29-repo-import-list-helper.md
Normal file
834
docs/superpowers/plans/2026-05-29-repo-import-list-helper.md
Normal file
@@ -0,0 +1,834 @@
|
||||
# Repo Import List Helper 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:** Add a helper that scans parent folders for git repos and bulk-creates lists (with `WorkingDir` pre-filled) for the repos the user ticks.
|
||||
|
||||
**Architecture:** A pure `RepoScanner` finds git repos under a parent folder. A `RepoImportModalViewModel` loads existing lists' working dirs, merges scanned candidates into a checklist (marking already-added repos), and creates `ListEntity` rows for ticked-new repos via `ListRepository`. `RepoImportModalView` hosts the checklist and a folder picker. Two entry points open the modal: a Help-menu item (handled by `IslandsShellViewModel`) and a folder button in the Lists island (handled by `ListsIslandViewModel`). Each entry point reloads the Lists island after the modal closes.
|
||||
|
||||
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, EF Core (SQLite), xUnit.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create:**
|
||||
- `src/ClaudeDo.Ui/Services/RepoScanner.cs` — pure filesystem scan; `RepoCandidate` record + `RepoScanner.Scan`.
|
||||
- `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs` — one checklist row.
|
||||
- `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs` — modal VM (load, merge, create) + static `BuildCandidates`.
|
||||
- `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml` (+ `.axaml.cs`) — modal window + folder picker.
|
||||
- `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs` — scanner unit tests.
|
||||
- `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs` — merge/dedupe/already-added unit tests.
|
||||
|
||||
**Modify:**
|
||||
- `src/ClaudeDo.App/Program.cs` — register `RepoImportModalViewModel` (transient) + a `Func<RepoImportModalViewModel>`; pass the Func into `IslandsShellViewModel`.
|
||||
- `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs` — `ShowRepoImportModal` Func + `OpenRepoImportCommand`.
|
||||
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml` — folder button beside `+ New list`.
|
||||
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs` — wire `ShowRepoImportModal`.
|
||||
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — `ShowRepoImportModal` Func + `OpenRepoImportCommand`; inject `Func<RepoImportModalViewModel>`.
|
||||
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` — Help-menu item `Add repos as lists…`.
|
||||
- `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowRepoImportModal`.
|
||||
- `src/ClaudeDo.Ui/CLAUDE.md` — document the new modal + entry points.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: RepoScanner
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/Services/RepoScanner.cs`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
public sealed class RepoScannerTests : IDisposable
|
||||
{
|
||||
private readonly string _root =
|
||||
Path.Combine(Path.GetTempPath(), "repo-scan-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public RepoScannerTests() => Directory.CreateDirectory(_root);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private string MakeDir(string name)
|
||||
{
|
||||
var p = Path.Combine(_root, name);
|
||||
Directory.CreateDirectory(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scan_ReturnsSubfoldersWithGitDirectory()
|
||||
{
|
||||
var repo = MakeDir("repo-a");
|
||||
Directory.CreateDirectory(Path.Combine(repo, ".git"));
|
||||
|
||||
var result = RepoScanner.Scan(_root);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal("repo-a", result[0].Name);
|
||||
Assert.Equal(repo, result[0].FullPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scan_TreatsDotGitFileAsRepo()
|
||||
{
|
||||
var repo = MakeDir("worktree-repo");
|
||||
File.WriteAllText(Path.Combine(repo, ".git"), "gitdir: ../somewhere");
|
||||
|
||||
var result = RepoScanner.Scan(_root);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal("worktree-repo", result[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scan_IgnoresPlainFolders()
|
||||
{
|
||||
MakeDir("not-a-repo");
|
||||
|
||||
var result = RepoScanner.Scan(_root);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scan_IsNotRecursive()
|
||||
{
|
||||
var nested = MakeDir(Path.Combine("outer", "inner"));
|
||||
Directory.CreateDirectory(Path.Combine(nested, ".git"));
|
||||
// outer itself has no .git
|
||||
|
||||
var result = RepoScanner.Scan(_root);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scan_ReturnsEmptyForMissingFolder()
|
||||
{
|
||||
var result = RepoScanner.Scan(Path.Combine(_root, "does-not-exist"));
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoScannerTests`
|
||||
Expected: FAIL — `RepoScanner` / `RepoCandidate` do not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Implement RepoScanner**
|
||||
|
||||
Create `src/ClaudeDo.Ui/Services/RepoScanner.cs`:
|
||||
|
||||
```csharp
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed record RepoCandidate(string Name, string FullPath);
|
||||
|
||||
public static class RepoScanner
|
||||
{
|
||||
public static IReadOnlyList<RepoCandidate> Scan(string parentFolder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parentFolder) || !Directory.Exists(parentFolder))
|
||||
return Array.Empty<RepoCandidate>();
|
||||
|
||||
var result = new List<RepoCandidate>();
|
||||
IEnumerable<string> subdirs;
|
||||
try { subdirs = Directory.EnumerateDirectories(parentFolder); }
|
||||
catch { return Array.Empty<RepoCandidate>(); }
|
||||
|
||||
foreach (var dir in subdirs)
|
||||
{
|
||||
var gitPath = Path.Combine(dir, ".git");
|
||||
if (Directory.Exists(gitPath) || File.Exists(gitPath))
|
||||
result.Add(new RepoCandidate(Path.GetFileName(dir), dir));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoScannerTests`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Services/RepoScanner.cs tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs
|
||||
git commit -m "feat(ui): add RepoScanner for git repo discovery"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: RepoImportItemViewModel
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs`
|
||||
|
||||
No dedicated test (trivial display VM; covered indirectly by Task 3).
|
||||
|
||||
- [ ] **Step 1: Implement the item VM**
|
||||
|
||||
Create `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs`:
|
||||
|
||||
```csharp
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class RepoImportItemViewModel : ViewModelBase
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public string FullPath { get; init; } = "";
|
||||
|
||||
// True when a list already points at this path. Such rows are shown ticked + disabled.
|
||||
public bool AlreadyAdded { get; init; }
|
||||
public bool CanToggle => !AlreadyAdded;
|
||||
|
||||
[ObservableProperty] private bool _isChecked;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs
|
||||
git commit -m "feat(ui): add RepoImportItemViewModel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: RepoImportModalViewModel
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs`
|
||||
|
||||
The pure `BuildCandidates` static method is the tested seam (dedupe + already-added marking). `LoadAsync`/`CreateAsync` touch the DB and are verified manually.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
public sealed class RepoImportCandidatesTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCandidates_NewRepo_IsCheckedAndNotAlreadyAdded()
|
||||
{
|
||||
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
|
||||
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
|
||||
|
||||
Assert.Single(items);
|
||||
Assert.True(items[0].IsChecked);
|
||||
Assert.False(items[0].AlreadyAdded);
|
||||
Assert.Equal("repo-a", items[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCandidates_ExistingWorkingDir_IsMarkedAlreadyAdded()
|
||||
{
|
||||
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
|
||||
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var existing = new HashSet<string>(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
|
||||
|
||||
Assert.Single(items);
|
||||
Assert.True(items[0].AlreadyAdded);
|
||||
Assert.True(items[0].IsChecked); // already-added rows render ticked
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCandidates_SkipsPathsAlreadyShown()
|
||||
{
|
||||
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
|
||||
var current = new HashSet<string>(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase);
|
||||
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
|
||||
|
||||
Assert.Empty(items);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoImportCandidatesTests`
|
||||
Expected: FAIL — `RepoImportModalViewModel` does not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Implement the modal VM**
|
||||
|
||||
Create `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class RepoImportModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ObservableCollection<RepoImportItemViewModel> Repos { get; } = new();
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded);
|
||||
public bool CanCreate => CreateCount > 0;
|
||||
public string CreateButtonText => $"Create {CreateCount} list(s)";
|
||||
|
||||
public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
Repos.Clear();
|
||||
_existingDirs.Clear();
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var lists = new ListRepository(ctx);
|
||||
foreach (var l in await lists.GetAllAsync(ct))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(l.WorkingDir))
|
||||
_existingDirs.Add(l.WorkingDir!);
|
||||
}
|
||||
NotifyCreateState();
|
||||
}
|
||||
|
||||
public void AddFolders(IEnumerable<string> folders)
|
||||
{
|
||||
var current = new HashSet<string>(
|
||||
Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
var found = RepoScanner.Scan(folder);
|
||||
foreach (var item in BuildCandidates(found, current, _existingDirs))
|
||||
{
|
||||
item.PropertyChanged += OnItemChanged;
|
||||
Repos.Add(item);
|
||||
current.Add(item.FullPath);
|
||||
}
|
||||
}
|
||||
NotifyCreateState();
|
||||
}
|
||||
|
||||
public static List<RepoImportItemViewModel> BuildCandidates(
|
||||
IEnumerable<RepoCandidate> found,
|
||||
IReadOnlySet<string> currentPaths,
|
||||
IReadOnlySet<string> existingDirs)
|
||||
{
|
||||
var items = new List<RepoImportItemViewModel>();
|
||||
foreach (var c in found)
|
||||
{
|
||||
if (currentPaths.Contains(c.FullPath)) continue;
|
||||
items.Add(new RepoImportItemViewModel
|
||||
{
|
||||
Name = c.Name,
|
||||
FullPath = c.FullPath,
|
||||
AlreadyAdded = existingDirs.Contains(c.FullPath),
|
||||
IsChecked = true,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private void OnItemChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(RepoImportItemViewModel.IsChecked))
|
||||
NotifyCreateState();
|
||||
}
|
||||
|
||||
private void NotifyCreateState()
|
||||
{
|
||||
OnPropertyChanged(nameof(CreateCount));
|
||||
OnPropertyChanged(nameof(CanCreate));
|
||||
OnPropertyChanged(nameof(CreateButtonText));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
var toCreate = Repos.Where(r => r.IsChecked && !r.AlreadyAdded).ToList();
|
||||
if (toCreate.Count > 0)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var lists = new ListRepository(ctx);
|
||||
foreach (var r in toCreate)
|
||||
{
|
||||
await lists.AddAsync(new ListEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Name = r.Name,
|
||||
WorkingDir = r.FullPath,
|
||||
DefaultCommitType = CommitTypeRegistry.DefaultType,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel() => CloseAction?.Invoke();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoImportCandidatesTests`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs
|
||||
git commit -m "feat(ui): add RepoImportModalViewModel with candidate merge logic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: RepoImportModalView
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml`
|
||||
- Create: `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs`
|
||||
|
||||
Modeled on `AboutModalView.axaml` (header/body/footer) and `ListSettingsModalView.axaml.cs` (folder picker).
|
||||
|
||||
- [ ] **Step 1: Create the view XAML**
|
||||
|
||||
Create `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml`:
|
||||
|
||||
```xml
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||
x:Class="ClaudeDo.Ui.Views.Modals.RepoImportModalView"
|
||||
x:DataType="vm:RepoImportModalViewModel"
|
||||
Title="Add repos as lists"
|
||||
Width="560" Height="480"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1">
|
||||
<Grid RowDefinitions="36,Auto,*,52">
|
||||
|
||||
<!-- Header -->
|
||||
<Border Grid.Row="0" Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
|
||||
<TextBlock Text="ADD REPOS AS LISTS" FontFamily="{DynamicResource MonoFont}" FontSize="11"
|
||||
LetterSpacing="1.4" Foreground="{DynamicResource TextBrush}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Classes="icon-btn" Content="✕" FontSize="12"
|
||||
Command="{Binding CancelCommand}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Add folder row -->
|
||||
<Border Grid.Row="1" Padding="16,12,16,4">
|
||||
<Button Content="Add folder…" Click="AddFolderClicked" HorizontalAlignment="Left"/>
|
||||
</Border>
|
||||
|
||||
<!-- Repo checklist -->
|
||||
<ScrollViewer Grid.Row="2" Padding="16,4,16,8">
|
||||
<ItemsControl ItemsSource="{Binding Repos}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:RepoImportItemViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,4">
|
||||
<CheckBox Grid.Column="0"
|
||||
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
||||
IsEnabled="{Binding CanToggle}"
|
||||
VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Column="1" Margin="6,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource TextBrush}" FontSize="13"/>
|
||||
<TextBlock Text="{Binding FullPath}" Foreground="{DynamicResource TextFaintBrush}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="(already added)"
|
||||
Foreground="{DynamicResource TextFaintBrush}" FontSize="11"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding AlreadyAdded}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Footer -->
|
||||
<Border Grid.Row="3" Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center" Margin="16,0">
|
||||
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
|
||||
<Button Content="{Binding CreateButtonText}" Command="{Binding CreateCommand}"
|
||||
IsEnabled="{Binding CanCreate}" MinWidth="120" Classes="accent"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the code-behind with folder picker**
|
||||
|
||||
Create `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs`:
|
||||
|
||||
```csharp
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Modals;
|
||||
|
||||
public partial class RepoImportModalView : Window
|
||||
{
|
||||
public RepoImportModalView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
BeginMoveDrag(e);
|
||||
}
|
||||
|
||||
private async void AddFolderClicked(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not RepoImportModalViewModel vm) return;
|
||||
var top = TopLevel.GetTopLevel(this);
|
||||
if (top is null) return;
|
||||
|
||||
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Choose folders containing repos",
|
||||
AllowMultiple = true,
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
|
||||
vm.AddFolders(folders.Select(f => f.Path.LocalPath));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: Build succeeded. (`TitleBar_PointerPressed` is unused for now but kept for parity with other modals; if the build warns as error, leave it — other modals keep the same handler.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs
|
||||
git commit -m "feat(ui): add RepoImportModalView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: DI registration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.App/Program.cs:106` (after `ListSettingsModalViewModel` registration)
|
||||
|
||||
- [ ] **Step 1: Register the modal VM and its factory**
|
||||
|
||||
In `src/ClaudeDo.App/Program.cs`, after the line `sc.AddTransient<ListSettingsModalViewModel>();` add:
|
||||
|
||||
```csharp
|
||||
sc.AddTransient<RepoImportModalViewModel>();
|
||||
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
|
||||
```
|
||||
|
||||
(`RepoImportModalViewModel` is in namespace `ClaudeDo.Ui.ViewModels.Modals`, already imported in `Program.cs` via the existing modal VM usings — verify the using is present; if not, add `using ClaudeDo.Ui.ViewModels.Modals;`.)
|
||||
|
||||
- [ ] **Step 2: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.App/Program.cs
|
||||
git commit -m "chore(di): register RepoImportModalViewModel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Lists island entry point
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs`
|
||||
|
||||
- [ ] **Step 1: Add Func + command to the VM**
|
||||
|
||||
In `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`, next to the existing `ShowListSettingsModal` property (around line 30), add:
|
||||
|
||||
```csharp
|
||||
public Func<RepoImportModalViewModel, System.Threading.Tasks.Task>? ShowRepoImportModal { get; set; }
|
||||
```
|
||||
|
||||
Then add a command (place it near `CreateListAsync`, e.g. after the `OpenWorktreesOverviewAsync` command around line 71):
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task OpenRepoImportAsync()
|
||||
{
|
||||
if (ShowRepoImportModal is null || _services is null) return;
|
||||
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
|
||||
await vm.LoadAsync();
|
||||
await ShowRepoImportModal(vm);
|
||||
await LoadAsync();
|
||||
}
|
||||
```
|
||||
|
||||
(`RepoImportModalViewModel` is in `ClaudeDo.Ui.ViewModels.Modals`, already imported at the top of this file.)
|
||||
|
||||
- [ ] **Step 2: Add the folder button in XAML**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`, replace the existing `+ New list` button block (lines 171-183) with a row that holds both the new-list button and a folder-scan button:
|
||||
|
||||
```xml
|
||||
<!-- New list + import row -->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="new-list-btn"
|
||||
Command="{Binding CreateListCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<PathIcon Data="{StaticResource Icon.Plus}"
|
||||
Width="13" Height="13"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="New list" FontSize="12"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Grid.Column="1" Classes="icon-btn" Margin="6,0,0,0"
|
||||
Command="{Binding OpenRepoImportCommand}"
|
||||
ToolTip.Tip="Add repos as lists">
|
||||
<PathIcon Data="{StaticResource Icon.Folder}"
|
||||
Width="14" Height="14"
|
||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire the Func in the code-behind**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs`, inside the `DataContextChanged` handler (after the `vm.ShowWorktreesOverviewModal = ...` assignment, before the closing brace of the `if` block around line 66), add:
|
||||
|
||||
```csharp
|
||||
vm.ShowRepoImportModal = async modal =>
|
||||
{
|
||||
var window = new RepoImportModalView { DataContext = modal };
|
||||
modal.CloseAction = () => window.Close();
|
||||
var top = TopLevel.GetTopLevel(this) as Window;
|
||||
if (top is null) window.Show();
|
||||
else await window.ShowDialog(top);
|
||||
};
|
||||
```
|
||||
|
||||
(`RepoImportModalView` is in `ClaudeDo.Ui.Views.Modals`, already imported in this file.)
|
||||
|
||||
- [ ] **Step 4: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs
|
||||
git commit -m "feat(ui): add repo import button to Lists island"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Help-menu entry point
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
||||
- Modify: `src/ClaudeDo.App/Program.cs` (pass the Func into the shell VM)
|
||||
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
|
||||
|
||||
- [ ] **Step 1: Add Func, factory field, and command to the shell VM**
|
||||
|
||||
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`:
|
||||
|
||||
(a) Near the `ShowAboutModal` property (line 44), add:
|
||||
|
||||
```csharp
|
||||
public Func<RepoImportModalViewModel, Task>? ShowRepoImportModal { get; set; }
|
||||
```
|
||||
|
||||
(b) Add a backing field for the factory next to `_worktreesOverviewVmFactory` (declared as a private readonly field elsewhere in the class). Add:
|
||||
|
||||
```csharp
|
||||
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
|
||||
```
|
||||
|
||||
(c) Add a parameter to the public constructor (line 162-171) — append after `mergeVmFactory`:
|
||||
|
||||
```csharp
|
||||
Func<MergeModalViewModel> mergeVmFactory,
|
||||
Func<RepoImportModalViewModel> repoImportVmFactory)
|
||||
```
|
||||
|
||||
and in the constructor body assign it (next to `_mergeVmFactory = mergeVmFactory;`):
|
||||
|
||||
```csharp
|
||||
_repoImportVmFactory = repoImportVmFactory;
|
||||
```
|
||||
|
||||
(d) Add the command near `OpenAbout` (line 256):
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async Task OpenRepoImport()
|
||||
{
|
||||
if (ShowRepoImportModal is null || _repoImportVmFactory is null) return;
|
||||
var vm = _repoImportVmFactory();
|
||||
await vm.LoadAsync();
|
||||
await ShowRepoImportModal(vm);
|
||||
if (Lists is not null) await Lists.LoadAsync();
|
||||
}
|
||||
```
|
||||
|
||||
(`RepoImportModalViewModel` is in `ClaudeDo.Ui.ViewModels.Modals`, already imported in this file.)
|
||||
|
||||
- [ ] **Step 2: Pass the Func into the shell VM in DI**
|
||||
|
||||
`IslandsShellViewModel` is registered with `sc.AddSingleton<IslandsShellViewModel>();` (Program.cs:123), which resolves constructor params from the container. Since Task 5 registered `Func<RepoImportModalViewModel>`, no change to the registration call is required — the new constructor parameter resolves automatically. Verify by building in Step 5.
|
||||
|
||||
- [ ] **Step 3: Add the Help-menu item**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, inside the Help `MenuItem` (after the `About…` item at line 74), add:
|
||||
|
||||
```xml
|
||||
<MenuItem Header="Add repos as lists…" Command="{Binding OpenRepoImportCommand}"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire the Func in MainWindow code-behind**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, inside `OnDataContextChanged` (after the `vm.ShowWorktreesOverviewModal = ...` block, before the closing brace of the `if` at line 65), add:
|
||||
|
||||
```csharp
|
||||
vm.ShowRepoImportModal = async (modal) =>
|
||||
{
|
||||
var dlg = new RepoImportModalView { DataContext = modal };
|
||||
modal.CloseAction = () => dlg.Close();
|
||||
await dlg.ShowDialog(this);
|
||||
};
|
||||
```
|
||||
|
||||
(`RepoImportModalView` is in `ClaudeDo.Ui.Views.Modals`, already imported in this file.)
|
||||
|
||||
- [ ] **Step 5: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||
Expected: Build succeeded (this also builds the Ui project).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
|
||||
git commit -m "feat(ui): add 'Add repos as lists' Help-menu entry point"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Manual verification + docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Run the full Ui test suite**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests`
|
||||
Expected: PASS (all tests, including the new `RepoScannerTests` and `RepoImportCandidatesTests`).
|
||||
|
||||
- [ ] **Step 2: Manual smoke test**
|
||||
|
||||
Launch the app (`dotnet run --project src/ClaudeDo.App/ClaudeDo.App.csproj`). Verify:
|
||||
- Lists island shows a folder button next to `+ New list`; clicking it opens the modal.
|
||||
- Help menu shows `Add repos as lists…`; clicking it opens the same modal.
|
||||
- `Add folder…` → pick a parent folder containing git repos → repos appear as ticked rows; non-repo subfolders are absent.
|
||||
- A repo that already has a list appears ticked, disabled, with `(already added)`.
|
||||
- The confirm button reads `Create N list(s)` and is disabled when N is 0.
|
||||
- Confirming creates the lists; they appear in the Lists island immediately after the modal closes.
|
||||
|
||||
Note: if you cannot run the GUI in this environment, state that explicitly rather than claiming the UI works.
|
||||
|
||||
- [ ] **Step 3: Update CLAUDE.md**
|
||||
|
||||
In `src/ClaudeDo.Ui/CLAUDE.md`, under the `## Views` section, add a bullet:
|
||||
|
||||
```markdown
|
||||
- **RepoImportModalView** — bulk-creates lists from git repos discovered under chosen parent folders. Opened via the folder button beside "New list" in the Lists island, or the "Add repos as lists…" Help-menu item. Repos already wired to a list show as disabled/"(already added)".
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/CLAUDE.md
|
||||
git commit -m "docs(ui): document RepoImportModalView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** Entry points (Help menu — Task 7; Lists island button — Task 6); `RepoScanner` non-recursive `.git` dir/file detection (Task 1); `RepoImportModalViewModel` load existing dirs + merge + create (Task 3); already-added disabled rows + `(already added)` label (Tasks 2/3/4); combined multi-folder checklist with path dedupe (Task 3 `AddFolders`); defaults Name/WorkingDir/DefaultCommitType (Task 3 `CreateAsync`); reload Lists island after close (Tasks 6/7); DI registration (Task 5); tests for scanner + merge logic (Tasks 1/3). All spec sections map to a task.
|
||||
- **Type consistency:** `RepoCandidate(Name, FullPath)`, `RepoScanner.Scan`, `RepoImportItemViewModel{Name,FullPath,AlreadyAdded,CanToggle,IsChecked}`, `RepoImportModalViewModel{Repos,CreateCount,CanCreate,CreateButtonText,LoadAsync,AddFolders,BuildCandidates,CreateCommand,CancelCommand,ShowRepoImportModal,CloseAction}` used consistently across tasks.
|
||||
- **YAGNI:** No recursive scan, no inline rename, no per-list model/prompt/agent during import — all explicitly out of scope.
|
||||
655
docs/superpowers/plans/2026-05-29-worker-per-user-autostart.md
Normal file
655
docs/superpowers/plans/2026-05-29-worker-per-user-autostart.md
Normal file
@@ -0,0 +1,655 @@
|
||||
# Worker Per-User Autostart Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the worker's Windows service with a per-user logon Scheduled Task so the worker runs as the logged-in user (Claude auth works), windowless, with file logging and auto-restart.
|
||||
|
||||
**Architecture:** Worker becomes a windowless (`WinExe`) process with Serilog file logging and a single-instance mutex. The installer registers a hidden logon Scheduled Task (via `schtasks /Create /XML`), migrates away the old `ClaudeDoWorker` service, and manages the worker as a process. The app launches/restarts the worker as a process and ensures it's running.
|
||||
|
||||
**Tech Stack:** .NET 8, ASP.NET Core (worker), WPF (installer), Avalonia (app), Serilog, Windows Task Scheduler (`schtasks`), `sc.exe`.
|
||||
|
||||
**Build note:** `.slnx` fails on .NET 8 — always build individual `.csproj` files.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Worker**
|
||||
- Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — WinExe, Serilog packages, drop Hosting.WindowsServices.
|
||||
- Modify `src/ClaudeDo.Worker/Program.cs` — mutex, Serilog, remove `UseWindowsService`.
|
||||
|
||||
**Installer**
|
||||
- Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs` — pure XML builder.
|
||||
- Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` — migrate service + register task.
|
||||
- Rename/rewrite `StopServiceStep.cs` → `StopWorkerStep.cs`, `StartServiceStep.cs` → `StartWorkerStep.cs`.
|
||||
- Delete `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs`.
|
||||
- Modify `Pages/ServicePage/ServicePageViewModel.cs` + `ServicePageView.xaml` — drop account radios.
|
||||
- Modify `Core/InstallContext.cs` — drop `ServiceAccount`.
|
||||
- Modify `Pages/InstallPage/InstallPageViewModel.cs` — pipeline wiring.
|
||||
- Modify `App.xaml.cs` — DI registration.
|
||||
- Modify `Core/UninstallRunner.cs` — task delete + process kill.
|
||||
- Modify `Views/SettingsViewModel.cs` — use renamed steps.
|
||||
|
||||
**App**
|
||||
- Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs` — resolve worker exe path.
|
||||
- Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — process restart + ensure-running.
|
||||
- Modify `src/ClaudeDo.App/Program.cs` — register `WorkerLocator`, pass to shell VM if needed.
|
||||
|
||||
**Tests**
|
||||
- Create `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`.
|
||||
- Create `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Worker → WinExe + Serilog packages
|
||||
|
||||
**Files:** Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
|
||||
- [ ] **Step 1:** In the main `<PropertyGroup>` add `<OutputType>WinExe</OutputType>`. Remove the `Microsoft.Extensions.Hosting.WindowsServices` PackageReference. Add:
|
||||
|
||||
```xml
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds (packages restore).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Worker single-instance mutex + Serilog + drop UseWindowsService
|
||||
|
||||
**Files:** Modify `src/ClaudeDo.Worker/Program.cs`
|
||||
|
||||
- [ ] **Step 1:** At the very top of the file (before `var cfg = WorkerConfig.Load();`), add the single-instance guard:
|
||||
|
||||
```csharp
|
||||
using System.Threading;
|
||||
|
||||
// Single-instance per user session. Multiple launch paths exist (logon task,
|
||||
// app ensure-running, Restart button); a second instance exits cleanly instead
|
||||
// of fighting over the SignalR port.
|
||||
var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew);
|
||||
if (!createdNew)
|
||||
return; // another instance already owns the port; exit 0
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Remove the `builder.Host.UseWindowsService(...)` line (lines ~21-23 incl. the comment).
|
||||
|
||||
- [ ] **Step 3:** After `var builder = WebApplication.CreateBuilder(args);`, add Serilog file logging:
|
||||
|
||||
```csharp
|
||||
using Serilog;
|
||||
|
||||
var logRoot = ClaudeDo.Data.Paths.Expand(cfg.LogRoot);
|
||||
Directory.CreateDirectory(logRoot);
|
||||
builder.Host.UseSerilog((ctx, lc) => lc
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.File(
|
||||
System.IO.Path.Combine(logRoot, "worker-.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 7,
|
||||
shared: true));
|
||||
```
|
||||
|
||||
(If `cfg.LogRoot` is already absolute/expanded, `Paths.Expand` is a safe no-op. Verify `WorkerConfig` exposes `LogRoot`; if the property differs, use the actual name.)
|
||||
|
||||
- [ ] **Step 4:** At the very end of the file, after the run block, add `GC.KeepAlive(mutex);` to ensure the mutex isn't collected.
|
||||
|
||||
- [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds.
|
||||
|
||||
- [ ] **Step 6:** Run worker tests: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — Expected: all pass (set `CLAUDEDO_SKIP_CLI_PREFLIGHT=1` if needed; existing tests already handle this).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Scheduled-task XML builder (pure, TDD)
|
||||
|
||||
**Files:** Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs`, Test `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test:**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Installer.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public class ScheduledTaskXmlTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_EmbedsUserExeAndLogonTrigger()
|
||||
{
|
||||
var xml = ScheduledTaskXml.Build(
|
||||
userId: "MACHINE\\mika",
|
||||
workerExePath: @"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe",
|
||||
restartIntervalMinutes: 1);
|
||||
|
||||
Assert.Contains("<LogonTrigger>", xml);
|
||||
Assert.Contains("<UserId>MACHINE\\mika</UserId>", xml);
|
||||
Assert.Contains("<LogonType>InteractiveToken</LogonType>", xml);
|
||||
Assert.Contains("<Hidden>true</Hidden>", xml);
|
||||
Assert.Contains("<RunLevel>LeastPrivilege</RunLevel>", xml);
|
||||
Assert.Contains(@"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe", xml);
|
||||
Assert.Contains("<Interval>PT1M</Interval>", xml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ClampsRestartIntervalToOneMinuteMinimum()
|
||||
{
|
||||
var xml = ScheduledTaskXml.Build("M\\u", @"C:\w.exe", restartIntervalMinutes: 0);
|
||||
Assert.Contains("<Interval>PT1M</Interval>", xml);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, verify fail:** `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj --filter ScheduledTaskXmlTests` — Expected: FAIL (type missing).
|
||||
|
||||
- [ ] **Step 3: Implement:**
|
||||
|
||||
```csharp
|
||||
using System.Security;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
/// <summary>Builds a Task Scheduler definition XML for the per-user worker autostart.
|
||||
/// Pure function so it can be unit-tested without admin rights.</summary>
|
||||
public static class ScheduledTaskXml
|
||||
{
|
||||
public static string Build(string userId, string workerExePath, int restartIntervalMinutes)
|
||||
{
|
||||
var minutes = restartIntervalMinutes < 1 ? 1 : restartIntervalMinutes;
|
||||
var user = SecurityElement.Escape(userId);
|
||||
var cmd = SecurityElement.Escape(workerExePath);
|
||||
return $"""
|
||||
<?xml version="1.0" encoding="UTF-16"?>
|
||||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<Description>ClaudeDo background worker (per-user).</Description>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<LogonTrigger>
|
||||
<Enabled>true</Enabled>
|
||||
<UserId>{user}</UserId>
|
||||
</LogonTrigger>
|
||||
</Triggers>
|
||||
<Principals>
|
||||
<Principal id="Author">
|
||||
<UserId>{user}</UserId>
|
||||
<LogonType>InteractiveToken</LogonType>
|
||||
<RunLevel>LeastPrivilege</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>true</AllowHardTerminate>
|
||||
<StartWhenAvailable>true</StartWhenAvailable>
|
||||
<Hidden>true</Hidden>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<RestartOnFailure>
|
||||
<Interval>PT{minutes}M</Interval>
|
||||
<Count>3</Count>
|
||||
</RestartOnFailure>
|
||||
</Settings>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
<Command>{cmd}</Command>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>
|
||||
""";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run, verify pass:** same filter — Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: RegisterAutostartStep (migrate service + register task)
|
||||
|
||||
**Files:** Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs`
|
||||
|
||||
- [ ] **Step 1: Implement** (no unit test — shells out to `sc`/`schtasks`; logic kept thin):
|
||||
|
||||
```csharp
|
||||
using System.IO;
|
||||
using System.Security.Principal;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class RegisterAutostartStep : IInstallStep
|
||||
{
|
||||
public const string TaskName = "ClaudeDoWorker";
|
||||
private const string LegacyServiceName = "ClaudeDoWorker";
|
||||
|
||||
public string Name => "Register Autostart";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
|
||||
if (!File.Exists(workerExe))
|
||||
return StepResult.Fail($"Worker executable not found: {workerExe}");
|
||||
|
||||
// 1) Migrate away the legacy Windows service if present.
|
||||
progress.Report("Checking for legacy worker service...");
|
||||
var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
|
||||
if (queryExit == 0)
|
||||
{
|
||||
progress.Report("Removing legacy worker service...");
|
||||
await ProcessRunner.RunAsync("sc.exe", $"stop {LegacyServiceName}", null, progress, ct);
|
||||
await ProcessRunner.RunAsync("sc.exe", $"delete {LegacyServiceName}", null, progress, ct);
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var (q, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
|
||||
if (q != 0) break;
|
||||
await Task.Delay(1000, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Register (or replace) the per-user logon task.
|
||||
var userId = WindowsIdentity.GetCurrent().Name;
|
||||
var minutes = Math.Max(1, ctx.RestartDelayMs / 60000);
|
||||
var xml = ScheduledTaskXml.Build(userId, workerExe, minutes);
|
||||
|
||||
var xmlPath = Path.Combine(Path.GetTempPath(), $"ClaudeDoWorker-{Guid.NewGuid():N}.xml");
|
||||
await File.WriteAllTextAsync(xmlPath, xml, new System.Text.UnicodeEncoding(false, true), ct);
|
||||
try
|
||||
{
|
||||
progress.Report("Registering logon task...");
|
||||
var (exit, output) = await ProcessRunner.RunAsync(
|
||||
"schtasks.exe", $"/Create /TN \"{TaskName}\" /XML \"{xmlPath}\" /F", null, progress, ct);
|
||||
if (exit != 0)
|
||||
return StepResult.Fail($"schtasks /Create failed (exit {exit}): {output}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(xmlPath); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
return StepResult.Ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build:** `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds (after Task 5/6 it compiles fully; if `RestartDelayMs` exists on `InstallContext` already, this compiles now).
|
||||
|
||||
---
|
||||
|
||||
## Task 5: StopWorkerStep + StartWorkerStep (replace service steps)
|
||||
|
||||
**Files:** Create `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`, `src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`. Delete `StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`.
|
||||
|
||||
- [ ] **Step 1: Create `StopWorkerStep.cs`:**
|
||||
|
||||
```csharp
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class StopWorkerStep : IInstallStep
|
||||
{
|
||||
public const string TaskName = "ClaudeDoWorker";
|
||||
public const string ProcessName = "ClaudeDo.Worker";
|
||||
|
||||
public string Name => "Stop Worker";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report("Stopping worker task (if running)...");
|
||||
await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct);
|
||||
|
||||
progress.Report("Stopping worker process (if running)...");
|
||||
var installDir = ctx.InstallDirectory;
|
||||
foreach (var p in Process.GetProcessesByName(ProcessName))
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = p.MainModule?.FileName;
|
||||
if (path is not null && !IsUnder(path, installDir)) continue;
|
||||
p.Kill(entireProcessTree: true);
|
||||
p.WaitForExit(10000);
|
||||
}
|
||||
catch { /* process may have exited or be inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
private static bool IsUnder(string filePath, string dir)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir)) return true; // can't scope — be permissive
|
||||
var full = Path.GetFullPath(filePath);
|
||||
var root = Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return full.StartsWith(root, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `StartWorkerStep.cs`:**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class StartWorkerStep : IInstallStep
|
||||
{
|
||||
public const string TaskName = "ClaudeDoWorker";
|
||||
|
||||
public string Name => "Start Worker";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report("Starting worker...");
|
||||
var (exit, output) = await ProcessRunner.RunAsync("schtasks.exe", $"/Run /TN \"{TaskName}\"", null, progress, ct);
|
||||
if (exit != 0)
|
||||
return StepResult.Fail($"schtasks /Run failed (exit {exit}): {output}");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** Delete `src/ClaudeDo.Installer/Steps/StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`.
|
||||
|
||||
- [ ] **Step 4:** Grep for remaining references: `StopServiceStep`, `StartServiceStep`, `RegisterServiceStep` across `src/` — fix each (Tasks 6-9 cover them).
|
||||
|
||||
---
|
||||
|
||||
## Task 6: InstallContext + ServicePage cleanup
|
||||
|
||||
**Files:** Modify `src/ClaudeDo.Installer/Core/InstallContext.cs`, `Pages/ServicePage/ServicePageViewModel.cs`, `Pages/ServicePage/ServicePageView.xaml`
|
||||
|
||||
- [ ] **Step 1:** In `InstallContext.cs` remove the `ServiceAccount` property (keep `AutoStart`, `RestartDelayMs`, `SignalRPort`, `ClaudeBin`, etc.).
|
||||
|
||||
- [ ] **Step 2:** In `ServicePageViewModel.cs` remove `IsLocalSystem`/`IsCurrentUser` `[ObservableProperty]` fields and the `_context.ServiceAccount = ...` line in `ApplyAsync`. Keep port/claudeBin/autostart/restartDelay.
|
||||
|
||||
- [ ] **Step 3:** In `ServicePageView.xaml` remove the radio buttons / account-selection UI bound to those properties. Leave the rest.
|
||||
|
||||
- [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 7-9.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Pipeline wiring + DI
|
||||
|
||||
**Files:** Modify `Pages/InstallPage/InstallPageViewModel.cs`, `App.xaml.cs`
|
||||
|
||||
- [ ] **Step 1:** In `InstallPageViewModel.LoadAsync`, update the **Update** display steps to:
|
||||
|
||||
```csharp
|
||||
Steps.Add(new StepViewModel("Stop Worker"));
|
||||
Steps.Add(new StepViewModel("Download and Extract"));
|
||||
Steps.Add(new StepViewModel("Register Autostart"));
|
||||
Steps.Add(new StepViewModel("Start Worker"));
|
||||
Steps.Add(new StepViewModel("Write Install Manifest"));
|
||||
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
|
||||
```
|
||||
|
||||
And the **Fresh** display steps to:
|
||||
|
||||
```csharp
|
||||
Steps.Add(new StepViewModel("Download and Extract"));
|
||||
Steps.Add(new StepViewModel("Write Configuration"));
|
||||
Steps.Add(new StepViewModel("Initialize Database"));
|
||||
Steps.Add(new StepViewModel("Register Autostart"));
|
||||
Steps.Add(new StepViewModel("Create Shortcuts"));
|
||||
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
|
||||
Steps.Add(new StepViewModel("Write Install Manifest"));
|
||||
Steps.Add(new StepViewModel("Start Worker"));
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** In `RunInstallAsync`, set the Update execution list to:
|
||||
|
||||
```csharp
|
||||
steps = new IInstallStep[]
|
||||
{
|
||||
_serviceProvider.GetRequiredService<StopWorkerStep>(),
|
||||
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
|
||||
_serviceProvider.GetRequiredService<RegisterAutostartStep>(),
|
||||
_serviceProvider.GetRequiredService<StartWorkerStep>(),
|
||||
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
|
||||
_serviceProvider.GetRequiredService<WriteUninstallRegistryStep>(),
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** In `App.xaml.cs` `BuildServices`, replace the service-step registrations. Fresh-install `IInstallStep` order must be: Download, WriteConfig, InitDatabase, **RegisterAutostart**, CreateShortcuts, WriteUninstallRegistry, WriteInstallManifest, **StartWorker**. Register:
|
||||
|
||||
```csharp
|
||||
sc.AddSingleton<DownloadAndExtractStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
|
||||
sc.AddSingleton<IInstallStep, WriteConfigStep>();
|
||||
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
||||
sc.AddSingleton<RegisterAutostartStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
|
||||
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
||||
sc.AddSingleton<WriteUninstallRegistryStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteUninstallRegistryStep>());
|
||||
sc.AddSingleton<WriteInstallManifestStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
|
||||
sc.AddSingleton<StartWorkerStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartWorkerStep>());
|
||||
|
||||
// Not part of the default fresh IEnumerable<IInstallStep> — pulled individually.
|
||||
sc.AddSingleton<StopWorkerStep>();
|
||||
```
|
||||
|
||||
Remove old `StopServiceStep`/`StartServiceStep`/`RegisterServiceStep` registrations.
|
||||
|
||||
- [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 8-9.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: SettingsViewModel + UninstallRunner
|
||||
|
||||
**Files:** Modify `Views/SettingsViewModel.cs`, `Core/UninstallRunner.cs`
|
||||
|
||||
- [ ] **Step 1:** In `SettingsViewModel.cs`, change ctor params/fields `StopServiceStep`/`StartServiceStep` → `StopWorkerStep`/`StartWorkerStep` (rename type usages only; the Save/Repair logic stays). Update the `Repair` step array to `{ _stopWorker, _downloadStep, _startWorker }`.
|
||||
|
||||
- [ ] **Step 2:** In `UninstallRunner.cs`:
|
||||
- Constructor param `StopServiceStep` → `StopWorkerStep` (field too).
|
||||
- Replace `sc.exe delete ClaudeDoWorker` with task removal + legacy service cleanup:
|
||||
|
||||
```csharp
|
||||
// 3) Unregister autostart task + remove any legacy service.
|
||||
progress.Report("Removing autostart task...");
|
||||
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{StopWorkerStep.TaskName}\" /F", null, progress, ct);
|
||||
await ProcessRunner.RunAsync("sc.exe", "delete ClaudeDoWorker", null, progress, ct); // legacy, best-effort
|
||||
```
|
||||
|
||||
- The existing `_stopService.ExecuteAsync` call becomes `_stopWorker.ExecuteAsync` (kills the worker process before deleting files).
|
||||
|
||||
- [ ] **Step 3:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: **succeeds, 0 errors**.
|
||||
|
||||
- [ ] **Step 4:** Run installer tests: `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj` — Expected: all pass (incl. new `ScheduledTaskXmlTests`).
|
||||
|
||||
---
|
||||
|
||||
## Task 9: App WorkerLocator (TDD)
|
||||
|
||||
**Files:** Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs`, Test `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing test:**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Ui.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.Services;
|
||||
|
||||
public class WorkerLocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void FindByWalkingUp_FindsWorkerExeBesideInstallJson()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "claudedo_wl_" + Guid.NewGuid().ToString("N"));
|
||||
var appDir = Path.Combine(root, "app");
|
||||
var workerDir = Path.Combine(root, "worker");
|
||||
Directory.CreateDirectory(appDir);
|
||||
Directory.CreateDirectory(workerDir);
|
||||
File.WriteAllText(Path.Combine(root, "install.json"), "{}");
|
||||
var exe = Path.Combine(workerDir, "ClaudeDo.Worker.exe");
|
||||
File.WriteAllText(exe, "");
|
||||
|
||||
try
|
||||
{
|
||||
var found = new WorkerLocator().FindByWalkingUp(appDir);
|
||||
Assert.Equal(exe, found);
|
||||
}
|
||||
finally { Directory.Delete(root, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindByWalkingUp_ReturnsNullWhenNoManifest()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "claudedo_wl_none_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try { Assert.Null(new WorkerLocator().FindByWalkingUp(dir)); }
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, verify fail:** `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter WorkerLocatorTests` — Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement** (mirror `InstallerLocator`):
|
||||
|
||||
```csharp
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed class WorkerLocator
|
||||
{
|
||||
private const string InstallJson = "install.json";
|
||||
private const string WorkerExe = "ClaudeDo.Worker.exe";
|
||||
private const string WorkerSubdir = "worker";
|
||||
|
||||
public string? Find()
|
||||
=> FindByWalkingUp(AppContext.BaseDirectory)
|
||||
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
|
||||
|
||||
public string? FindByWalkingUp(string startDir)
|
||||
{
|
||||
var dir = new DirectoryInfo(startDir);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, InstallJson)))
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, WorkerSubdir, WorkerExe);
|
||||
return File.Exists(candidate) ? candidate : null;
|
||||
}
|
||||
dir = dir.Parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
public string? FindByRegistry()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return null;
|
||||
try
|
||||
{
|
||||
using var key = Microsoft.Win32.Registry.LocalMachine
|
||||
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
|
||||
var location = key?.GetValue("InstallLocation") as string;
|
||||
if (string.IsNullOrEmpty(location)) return null;
|
||||
var candidate = Path.Combine(location, WorkerSubdir, WorkerExe);
|
||||
return File.Exists(candidate) ? candidate : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run, verify pass.**
|
||||
|
||||
---
|
||||
|
||||
## Task 10: App restart-worker + ensure-running
|
||||
|
||||
**Files:** Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, `src/ClaudeDo.App/Program.cs`
|
||||
|
||||
- [ ] **Step 1:** In `App/Program.cs` register the locator: `sc.AddSingleton<WorkerLocator>();` and ensure `IslandsShellViewModel` receives it (constructor injection; the VM is `AddSingleton<IslandsShellViewModel>()` so DI supplies it).
|
||||
|
||||
- [ ] **Step 2:** In `IslandsShellViewModel`, add a `WorkerLocator` constructor dependency and store it. Replace `RestartWorkerService` (the `ServiceController` version) with a process relaunch:
|
||||
|
||||
```csharp
|
||||
private void RestartWorkerService()
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
|
||||
|
||||
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
|
||||
{
|
||||
try { p.Kill(entireProcessTree: true); p.WaitForExit(10000); }
|
||||
catch { /* may have exited */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
|
||||
}
|
||||
```
|
||||
|
||||
Update `RestartWorkerAsync` messages accordingly (drop the "service not installed" `InvalidOperationException` branch wording → generic failure).
|
||||
|
||||
- [ ] **Step 3:** Add ensure-running on startup. After the VM wires up the worker connection, schedule a one-shot check:
|
||||
|
||||
```csharp
|
||||
private bool _ensureRunningAttempted;
|
||||
|
||||
private async Task EnsureWorkerRunningAsync()
|
||||
{
|
||||
if (_ensureRunningAttempted) return;
|
||||
_ensureRunningAttempted = true;
|
||||
await Task.Delay(TimeSpan.FromSeconds(4));
|
||||
if (_worker.IsConnected) return;
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is null) return;
|
||||
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); }
|
||||
catch { /* logon task is the primary mechanism; this is a convenience */ }
|
||||
}
|
||||
```
|
||||
|
||||
Call `_ = EnsureWorkerRunningAsync();` from the VM's existing init path (where the connection is started). Use the actual `WorkerClient` field name and its `IsConnected` member.
|
||||
|
||||
- [ ] **Step 4:** Remove `using System.ServiceProcess;` and the `ServiceController` usage. Remove the `System.ServiceProcess.ServiceProcess` package reference from `ClaudeDo.Ui.csproj` if present and now unused.
|
||||
|
||||
- [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` — Expected: succeeds.
|
||||
|
||||
- [ ] **Step 6:** Run UI tests: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj` — Expected: all pass (incl. `WorkerLocatorTests`). If `IslandsShellViewModel` construction is exercised in a test, supply a `WorkerLocator` instance.
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Full build + test sweep
|
||||
|
||||
- [ ] **Step 1:** Build each project:
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
|
||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
|
||||
```
|
||||
Expected: all succeed, 0 errors.
|
||||
|
||||
- [ ] **Step 2:** Run all test projects:
|
||||
```bash
|
||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
|
||||
dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj
|
||||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj
|
||||
dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj
|
||||
```
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 3:** Grep for leftovers: `ServiceController`, `UseWindowsService`, `RegisterServiceStep`, `StopServiceStep`, `StartServiceStep`, `ServiceAccount` in `src/` — Expected: no matches (except the legacy `sc delete ClaudeDoWorker` migration/cleanup strings).
|
||||
|
||||
---
|
||||
|
||||
## Notes for the implementer
|
||||
- Worker config property for the log directory: confirm the exact name on `WorkerConfig` (spec assumes `LogRoot`). Use the real one.
|
||||
- `ProcessRunner.RunAsync` signature is `(string file, string args, string? workingDir, IProgress<string> progress, CancellationToken ct)` returning `(int ExitCode, string Output)` — match existing call sites.
|
||||
- Keep the legacy `sc delete ClaudeDoWorker` calls (migration + uninstall) so existing service installs are cleaned up.
|
||||
970
docs/superpowers/plans/2026-05-30-external-mcp-ui-parity.md
Normal file
970
docs/superpowers/plans/2026-05-30-external-mcp-ui-parity.md
Normal file
@@ -0,0 +1,970 @@
|
||||
# External MCP — UI Parity (Start & Observe) 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:** Add MCP tools so an external Claude session can fully *start* and *observe* ClaudeDo sessions (list/config management, run history, logs, agent listing, reset-failed, app-settings read), reaching UI parity for those concerns.
|
||||
|
||||
**Architecture:** New focused `[McpServerToolType]` classes in `src/ClaudeDo.Worker/External/`, each injecting an existing worker service (no logic duplication). All registered in the *external* `WebApplication` DI container in `Program.cs`. Mutations broadcast the same SignalR events the hub raises, keeping the UI in sync.
|
||||
|
||||
**Tech Stack:** .NET 8, `ModelContextProtocol.Server`, EF Core (SQLite), xUnit integration tests (real SQLite via `DbFixture`).
|
||||
|
||||
> **Build/test note (from project memory):** `dotnet build ClaudeDo.slnx` fails on .NET 8. Build the csproj directly:
|
||||
> `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
> Test: `dotnet test tests/ClaudeDo.Worker.Tests`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create:**
|
||||
- `src/ClaudeDo.Worker/External/ListMcpTools.cs` — list create/update/delete tools
|
||||
- `src/ClaudeDo.Worker/External/ConfigMcpTools.cs` — list-config + task-config tools + DTO
|
||||
- `src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs` — run history + log read tools + DTO
|
||||
- `src/ClaudeDo.Worker/External/AgentMcpTools.cs` — agent listing tool
|
||||
- `src/ClaudeDo.Worker/External/LifecycleMcpTools.cs` — reset-failed-task tool
|
||||
- `src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs` — app-settings read tool
|
||||
- `tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs`
|
||||
- `tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs`
|
||||
- `tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs`
|
||||
- `tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs`
|
||||
|
||||
**Modify:**
|
||||
- `src/ClaudeDo.Worker/Program.cs:188-217` — register new tool classes + services in the external builder
|
||||
- `src/ClaudeDo.Worker/CLAUDE.md:27` — remove stale tag tools, refresh the External MCP tool inventory
|
||||
|
||||
**Reference (existing, do not change):**
|
||||
- `ListRepository` — `AddAsync`, `UpdateAsync`, `DeleteAsync`, `GetByIdAsync`, `GetAllAsync`, `GetConfigAsync`, `SetConfigAsync`, `DeleteConfigAsync`
|
||||
- `TaskRepository.UpdateAgentSettingsAsync(taskId, model?, systemPrompt?, agentPath?)`
|
||||
- `TaskRunRepository` — `GetByTaskIdAsync`, `GetByIdAsync`, `GetLatestByTaskIdAsync`
|
||||
- `TaskResetService.ResetAsync(taskId, ct)` — refuses Running, discards worktree, resets to Idle
|
||||
- `AgentFileService.ScanAsync(ct)` → `List<AgentInfo>`; `AgentInfo(string Name, string Description, string Path)`
|
||||
- `AppSettingsRepository.GetAsync()` → `AppSettingsEntity`
|
||||
- `TaskRunEntity` fields: `Id, TaskId, RunNumber, SessionId, IsRetry, ResultMarkdown, StructuredOutputJson, ErrorMarkdown, ExitCode, TurnCount, TokensIn, TokensOut, LogPath, StartedAt, FinishedAt`
|
||||
- `CommitTypeRegistry.DefaultType`
|
||||
- `HubBroadcaster.ListUpdated(id)`, `.TaskUpdated(id)`
|
||||
|
||||
> **Spec refinement (YAGNI):** the spec listed an agent "refresh" tool. `AgentFileService.ScanAsync` reads disk fresh on every call, so a separate refresh is redundant for an MCP client. We implement `ListAgents` only.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: List management tools (`ListMcpTools`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/External/ListMcpTools.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
internal sealed class ListToolsHubClients : IHubClients
|
||||
{
|
||||
public ListToolsClientProxy Proxy { get; } = new();
|
||||
public IClientProxy All => Proxy;
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> e) => Proxy;
|
||||
public IClientProxy Client(string c) => Proxy;
|
||||
public IClientProxy Clients(IReadOnlyList<string> c) => Proxy;
|
||||
public IClientProxy Group(string g) => Proxy;
|
||||
public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => Proxy;
|
||||
public IClientProxy Groups(IReadOnlyList<string> g) => Proxy;
|
||||
public IClientProxy User(string u) => Proxy;
|
||||
public IClientProxy Users(IReadOnlyList<string> u) => Proxy;
|
||||
}
|
||||
internal sealed class ListToolsClientProxy : IClientProxy
|
||||
{
|
||||
public Task SendCoreAsync(string m, object?[] a, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
internal sealed class ListToolsHubContext : IHubContext<WorkerHub>
|
||||
{
|
||||
public ListToolsHubClients RecordingClients { get; } = new();
|
||||
public IHubClients Clients => RecordingClients;
|
||||
public IGroupManager Groups => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public sealed class ListMcpToolsTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly ListMcpTools _sut;
|
||||
|
||||
public ListMcpToolsTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_lists = new ListRepository(_ctx);
|
||||
_sut = new ListMcpTools(_lists, new HubBroadcaster(new ListToolsHubContext()));
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
[Fact]
|
||||
public async Task CreateList_PersistsWithDefaults()
|
||||
{
|
||||
var dto = await _sut.CreateList("My List", null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("My List", dto.Name);
|
||||
var loaded = await _lists.GetByIdAsync(dto.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("chore", loaded!.DefaultCommitType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateList_PatchesNameWorkingDirAndCommitType()
|
||||
{
|
||||
var created = await _sut.CreateList("orig", null, null, CancellationToken.None);
|
||||
|
||||
var dto = await _sut.UpdateList(created.Id, "renamed", "C:/work", "feat", CancellationToken.None);
|
||||
|
||||
Assert.Equal("renamed", dto.Name);
|
||||
Assert.Equal("C:/work", dto.WorkingDir);
|
||||
var loaded = await _lists.GetByIdAsync(created.Id);
|
||||
Assert.Equal("feat", loaded!.DefaultCommitType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateList_NotFound_Throws()
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.UpdateList("missing", "x", null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteList_RemovesList()
|
||||
{
|
||||
var created = await _sut.CreateList("gone", null, null, CancellationToken.None);
|
||||
|
||||
await _sut.DeleteList(created.Id, CancellationToken.None);
|
||||
|
||||
Assert.Null(await _lists.GetByIdAsync(created.Id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests`
|
||||
Expected: FAIL — `ListMcpTools` does not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record ListSummaryDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class ListMcpTools
|
||||
{
|
||||
private readonly ListRepository _lists;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
|
||||
public ListMcpTools(ListRepository lists, HubBroadcaster broadcaster)
|
||||
{
|
||||
_lists = lists;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")]
|
||||
public async Task<ListSummaryDto> CreateList(
|
||||
string name, string? workingDir, string? commitType, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new InvalidOperationException("name is required.");
|
||||
|
||||
var entity = new ListEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = name,
|
||||
WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir,
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _lists.AddAsync(entity, cancellationToken);
|
||||
await _broadcaster.ListUpdated(entity.Id);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")]
|
||||
public async Task<ListSummaryDto> UpdateList(
|
||||
string listId, string? name, string? workingDir, string? commitType, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
|
||||
if (name is not null) entity.Name = name;
|
||||
if (workingDir is not null)
|
||||
entity.WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir;
|
||||
if (commitType is not null)
|
||||
entity.DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType;
|
||||
|
||||
await _lists.UpdateAsync(entity, cancellationToken);
|
||||
await _broadcaster.ListUpdated(listId);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Delete a list and its tasks. Irreversible.")]
|
||||
public async Task DeleteList(string listId, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
await _lists.DeleteAsync(listId, cancellationToken);
|
||||
await _broadcaster.ListUpdated(listId);
|
||||
}
|
||||
|
||||
private static ListSummaryDto ToDto(ListEntity l) =>
|
||||
new(l.Id, l.Name, l.WorkingDir, l.DefaultCommitType);
|
||||
}
|
||||
```
|
||||
|
||||
> If `CommitTypeRegistry` is not in scope, add `using ClaudeDo.Data;` (verify its namespace with a quick grep before assuming).
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ListMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs
|
||||
git commit -m "feat(worker): add external MCP list-management tools"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: List & task config tools (`ConfigMcpTools`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
public sealed class ConfigMcpToolsTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ConfigMcpTools _sut;
|
||||
|
||||
public ConfigMcpToolsTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_lists = new ListRepository(_ctx);
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_sut = new ConfigMcpTools(_lists, _tasks, new HubBroadcaster(new ListToolsHubContext()));
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private async Task<string> SeedListAsync()
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
return id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAndGetListConfig_RoundTrips()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
|
||||
await _sut.SetListConfig(listId, "sonnet", "be terse", null, CancellationToken.None);
|
||||
var cfg = await _sut.GetListConfig(listId, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(cfg);
|
||||
Assert.Equal("sonnet", cfg!.Model);
|
||||
Assert.Equal("be terse", cfg.SystemPrompt);
|
||||
Assert.Null(cfg.AgentPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetListConfig_AllNull_ClearsConfig()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
await _sut.SetListConfig(listId, "sonnet", null, null, CancellationToken.None);
|
||||
|
||||
await _sut.SetListConfig(listId, null, null, null, CancellationToken.None);
|
||||
|
||||
Assert.Null(await _sut.GetListConfig(listId, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTaskConfig_PersistsOverrides()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = "t",
|
||||
Status = ClaudeDo.Data.Models.TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
|
||||
await _sut.SetTaskConfig(task.Id, "opus", null, null, CancellationToken.None);
|
||||
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal("opus", loaded!.Model);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests`
|
||||
Expected: FAIL — `ConfigMcpTools` does not exist.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class ConfigMcpTools
|
||||
{
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
|
||||
public ConfigMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster)
|
||||
{
|
||||
_lists = lists;
|
||||
_tasks = tasks;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns null if no config is set.")]
|
||||
public async Task<TaskConfigDto?> GetListConfig(string listId, CancellationToken cancellationToken)
|
||||
{
|
||||
var cfg = await _lists.GetConfigAsync(listId, cancellationToken);
|
||||
return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Set a list's default model/system prompt/agent path. Passing all three as null clears the list config.")]
|
||||
public async Task SetListConfig(
|
||||
string listId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
|
||||
var m = Nullify(model);
|
||||
var sp = Nullify(systemPrompt);
|
||||
var ap = Nullify(agentPath);
|
||||
|
||||
if (m is null && sp is null && ap is null)
|
||||
await _lists.DeleteConfigAsync(listId, cancellationToken);
|
||||
else
|
||||
await _lists.SetConfigAsync(new ListConfigEntity
|
||||
{
|
||||
ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap,
|
||||
}, cancellationToken);
|
||||
|
||||
await _broadcaster.ListUpdated(listId);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path). Pass null to clear a field.")]
|
||||
public async Task SetTaskConfig(
|
||||
string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
await _tasks.UpdateAgentSettingsAsync(taskId, Nullify(model), Nullify(systemPrompt), Nullify(agentPath), cancellationToken);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
|
||||
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
}
|
||||
```
|
||||
|
||||
> Verify `UpdateAgentSettingsAsync` accepts a `CancellationToken` (read `TaskRepository.cs:157`). If it does not, drop the `cancellationToken` argument from that call.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ConfigMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs
|
||||
git commit -m "feat(worker): add external MCP list/task config tools"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Run history & log tools (`RunHistoryMcpTools`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRunRepository _runs;
|
||||
private readonly RunHistoryMcpTools _sut;
|
||||
|
||||
public RunHistoryMcpToolsTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_runs = new TaskRunRepository(_ctx);
|
||||
_sut = new RunHistoryMcpTools(_runs);
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private async Task SeedTaskAsync(string taskId)
|
||||
{
|
||||
var lists = new ListRepository(_ctx);
|
||||
var tasks = new TaskRepository(_ctx);
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
await tasks.AddAsync(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "t",
|
||||
Status = ClaudeDo.Data.Models.TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore",
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListRuns_ReturnsProjectedRuns()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
await _runs.AddAsync(new TaskRunEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
||||
IsRetry = false, Prompt = "p", ResultMarkdown = "done", TokensIn = 10, TokensOut = 20,
|
||||
});
|
||||
|
||||
var list = await _sut.ListRuns(taskId, CancellationToken.None);
|
||||
|
||||
Assert.Single(list);
|
||||
Assert.Equal("done", list[0].ResultMarkdown);
|
||||
Assert.Equal(10, list[0].TokensIn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskLog_NoLog_Throws()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.GetTaskLog(taskId, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskLog_ReadsLatestRunLogFile()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
|
||||
await File.WriteAllTextAsync(logPath, "hello log");
|
||||
await _runs.AddAsync(new TaskRunEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
||||
IsRetry = false, Prompt = "p", LogPath = logPath,
|
||||
});
|
||||
|
||||
var content = await _sut.GetTaskLog(taskId, CancellationToken.None);
|
||||
|
||||
Assert.Equal("hello log", content);
|
||||
File.Delete(logPath);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests`
|
||||
Expected: FAIL — `RunHistoryMcpTools` does not exist.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record RunDto(
|
||||
string Id, int RunNumber, string? SessionId, bool IsRetry,
|
||||
string? ResultMarkdown, string? StructuredOutputJson, string? ErrorMarkdown,
|
||||
int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut,
|
||||
DateTime? StartedAt, DateTime? FinishedAt);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class RunHistoryMcpTools
|
||||
{
|
||||
private readonly TaskRunRepository _runs;
|
||||
|
||||
public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs;
|
||||
|
||||
[McpServerTool, Description("List all execution runs for a task (newest run metadata, tokens, turns, result, error).")]
|
||||
public async Task<IReadOnlyList<RunDto>> ListRuns(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken);
|
||||
return runs.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Get a single execution run by its run id.")]
|
||||
public async Task<RunDto> GetRun(string runId, CancellationToken cancellationToken)
|
||||
{
|
||||
var run = await _runs.GetByIdAsync(runId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Run {runId} not found.");
|
||||
return ToDto(run);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")]
|
||||
public async Task<string> GetTaskLog(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"No runs found for task {taskId}.");
|
||||
if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
|
||||
throw new InvalidOperationException("No log available for the latest run.");
|
||||
return await File.ReadAllTextAsync(run.LogPath, cancellationToken);
|
||||
}
|
||||
|
||||
private static RunDto ToDto(TaskRunEntity r) => new(
|
||||
r.Id, r.RunNumber, r.SessionId, r.IsRetry,
|
||||
r.ResultMarkdown, r.StructuredOutputJson, r.ErrorMarkdown,
|
||||
r.ExitCode, r.TurnCount, r.TokensIn, r.TokensOut,
|
||||
r.StartedAt, r.FinishedAt);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs
|
||||
git commit -m "feat(worker): add external MCP run-history and log tools"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Agent listing tool (`AgentMcpTools`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/External/AgentMcpTools.cs`
|
||||
- Test: none new — covered indirectly; `AgentFileService` already has unit coverage. (This tool is a thin pass-through.)
|
||||
|
||||
- [ ] **Step 1: Write minimal implementation**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.Agents;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class AgentMcpTools
|
||||
{
|
||||
private readonly AgentFileService _agents;
|
||||
|
||||
public AgentMcpTools(AgentFileService agents) => _agents = agents;
|
||||
|
||||
[McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")]
|
||||
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
|
||||
=> await _agents.ScanAsync(cancellationToken);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/AgentMcpTools.cs
|
||||
git commit -m "feat(worker): add external MCP agent-listing tool"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Reset-failed-task tool (`LifecycleMcpTools`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/External/LifecycleMcpTools.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs`
|
||||
|
||||
`TaskResetService.ResetAsync` already refuses Running tasks and discards the worktree. The MCP tool adds a guard that the task must be `Failed` (the only sensible reset target via this surface) and delegates.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using ClaudeDo.Worker.Tests.Services;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
public sealed class LifecycleMcpToolsTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
|
||||
public LifecycleMcpToolsTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private LifecycleMcpTools BuildSut()
|
||||
{
|
||||
var cfg = new WorkerConfig
|
||||
{
|
||||
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
|
||||
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
|
||||
};
|
||||
var dbFactory = _db.CreateFactory();
|
||||
var broadcaster = new HubBroadcaster(new ListToolsHubContext());
|
||||
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||
var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger<TaskResetService>.Instance);
|
||||
return new LifecycleMcpTools(_tasks, reset);
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(TaskStatus status)
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t",
|
||||
Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetFailedTask_OnFailed_ResetsToIdle()
|
||||
{
|
||||
var task = await SeedTaskAsync(TaskStatus.Failed);
|
||||
var sut = BuildSut();
|
||||
|
||||
await sut.ResetFailedTask(task.Id, CancellationToken.None);
|
||||
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.Idle, loaded!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetFailedTask_OnNonFailed_Throws()
|
||||
{
|
||||
var task = await SeedTaskAsync(TaskStatus.Done);
|
||||
var sut = BuildSut();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.ResetFailedTask(task.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetFailedTask_NotFound_Throws()
|
||||
{
|
||||
var sut = BuildSut();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.ResetFailedTask("missing", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests`
|
||||
Expected: FAIL — `LifecycleMcpTools` does not exist.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ModelContextProtocol.Server;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class LifecycleMcpTools
|
||||
{
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly TaskResetService _reset;
|
||||
|
||||
public LifecycleMcpTools(TaskRepository tasks, TaskResetService reset)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_reset = reset;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted.")]
|
||||
public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status != TaskStatus.Failed)
|
||||
throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool.");
|
||||
|
||||
await _reset.ResetAsync(taskId, cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests`
|
||||
Expected: PASS (3 tests). (Git-dependent worktree discard is skipped when no worktree row exists — these tasks have none.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/LifecycleMcpTools.cs tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs
|
||||
git commit -m "feat(worker): add external MCP reset-failed-task tool"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: App-settings read tool (`AppSettingsMcpTools`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs`
|
||||
- Test: none new — thin read-only pass-through over `AppSettingsRepository.GetAsync`.
|
||||
|
||||
This tool is read-only by design (writing app settings is out of scope). It uses the db factory (registered as a singleton in the external builder) to open a context per call, mirroring the hub's pattern.
|
||||
|
||||
- [ ] **Step 1: Write minimal implementation**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record AppSettingsReadDto(
|
||||
string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode,
|
||||
string WorktreeStrategy, string? CentralWorktreeRoot,
|
||||
bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class AppSettingsMcpTools
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
|
||||
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
|
||||
|
||||
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")]
|
||||
public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
|
||||
{
|
||||
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var row = await new AppSettingsRepository(ctx).GetAsync();
|
||||
return new AppSettingsReadDto(
|
||||
row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode,
|
||||
row.WorktreeStrategy, row.CentralWorktreeRoot,
|
||||
row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Verify `AppSettingsRepository.GetAsync` signature (it may take a `CancellationToken`). Adjust the call if so. Confirm `AppSettingsEntity` property names match (`DefaultModel`, `DefaultMaxTurns`, `DefaultPermissionMode`, `WorktreeStrategy`, `CentralWorktreeRoot`, `WorktreeAutoCleanupEnabled`, `WorktreeAutoCleanupDays`) — they are used identically in `WorkerHub.GetAppSettings` (lines 206-219).
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs
|
||||
git commit -m "feat(worker): add external MCP app-settings read tool"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Register new tools in the external MCP app
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Program.cs:188-217`
|
||||
|
||||
The external `WebApplication` has its own DI container. Each new tool class and every service it needs must be registered there, and each class added via `.WithTools<T>()`.
|
||||
|
||||
- [ ] **Step 1: Add service + tool registrations**
|
||||
|
||||
In the `if (cfg.ExternalMcpPort > 0)` block, after the existing
|
||||
`externalBuilder.Services.AddScoped<ExternalMcpService>();` line, add:
|
||||
|
||||
```csharp
|
||||
externalBuilder.Services.AddScoped<TaskRunRepository>();
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
|
||||
externalBuilder.Services.AddScoped<TaskResetService>();
|
||||
externalBuilder.Services.AddScoped<ListMcpTools>();
|
||||
externalBuilder.Services.AddScoped<ConfigMcpTools>();
|
||||
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
|
||||
externalBuilder.Services.AddScoped<AgentMcpTools>();
|
||||
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
|
||||
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
|
||||
```
|
||||
|
||||
And extend the `AddMcpServer()` chain:
|
||||
|
||||
```csharp
|
||||
externalBuilder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithTools<ExternalMcpService>()
|
||||
.WithTools<ListMcpTools>()
|
||||
.WithTools<ConfigMcpTools>()
|
||||
.WithTools<RunHistoryMcpTools>()
|
||||
.WithTools<AgentMcpTools>()
|
||||
.WithTools<LifecycleMcpTools>()
|
||||
.WithTools<AppSettingsMcpTools>();
|
||||
```
|
||||
|
||||
> **Verify before editing:** confirm `WorktreeManager` and `AgentFileService` are registered as singletons in the *main* `app` container (grep `Program.cs` for `WorktreeManager` and `AgentFileService`). If `AgentFileService` is constructed with a directory string rather than DI-resolved, register it in the external builder the same way the main app does (e.g. `new AgentFileService(agentsDir)`), not via `GetRequiredService`. `TaskResetService` depends on `WorktreeManager`, `IDbContextFactory`, `HubBroadcaster`, `ITaskStateService`, `ILogger<TaskResetService>` — all already singletons in the external builder except `WorktreeManager` (added above) and the logger (provided by default logging).
|
||||
|
||||
- [ ] **Step 2: Build the worker**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: Build succeeded, no DI-related compile errors.
|
||||
|
||||
- [ ] **Step 3: Run the full worker test suite**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
|
||||
Expected: PASS (all existing + new tests).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Program.cs
|
||||
git commit -m "feat(worker): register new external MCP tool classes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Documentation cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/CLAUDE.md:27`
|
||||
|
||||
- [ ] **Step 1: Replace the stale External MCP inventory line**
|
||||
|
||||
Replace the line beginning `- **External/ExternalMcpService** — always-on MCP tools…` with an accurate inventory that drops the (non-existent) tag tools and lists the new surface:
|
||||
|
||||
```markdown
|
||||
- **External/*** — always-on MCP tools for general Claude sessions, organized by concern:
|
||||
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle`/`Queued`), `RunTaskNow`, `CancelTask`, `DeleteTask`
|
||||
- `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList`
|
||||
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `SetTaskConfig`
|
||||
- `RunHistoryMcpTools` — `ListRuns`, `GetRun`, `GetTaskLog`
|
||||
- `AgentMcpTools` — `ListAgents`
|
||||
- `LifecycleMcpTools` — `ResetFailedTask`
|
||||
- `AppSettingsMcpTools` — `GetAppSettings` (read-only)
|
||||
- Purpose is scoped to *starting* and *observing* sessions — no worktree/merge, multi-turn, planning, or app-settings writes. Auth via optional `X-ClaudeDo-Key` header.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/CLAUDE.md
|
||||
git commit -m "docs(worker): correct external MCP tool inventory, drop removed tags"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- List management → Task 1 ✓
|
||||
- List & task config → Task 2 ✓
|
||||
- Run history & logs → Task 3 ✓
|
||||
- Agents (read-only) → Task 4 ✓
|
||||
- Reset failed task → Task 5 ✓
|
||||
- App settings (read-only) → Task 6 ✓
|
||||
- DI wiring (separate external app) → Task 7 ✓
|
||||
- Tag doc cleanup → Task 8 ✓
|
||||
- Out-of-scope items (multi-turn, worktree ops, planning, app-settings writes, tags, agent create/edit) → not implemented ✓
|
||||
|
||||
**Placeholder scan:** No TBD/TODO. The three "verify before editing" notes point at real signatures the implementer must confirm (cancellation-token overloads, `AgentFileService` construction, registry namespaces) — these are verification steps with concrete fallbacks, not placeholders.
|
||||
|
||||
**Type consistency:** `ListSummaryDto`, `TaskConfigDto`, `RunDto`, `AppSettingsReadDto` defined once and used consistently. `AgentInfo` reused directly (no new DTO). Tool method names match between implementation, tests, and the Task-8 doc inventory (`CreateList`/`UpdateList`/`DeleteList`, `GetListConfig`/`SetListConfig`/`SetTaskConfig`, `ListRuns`/`GetRun`/`GetTaskLog`, `ListAgents`, `ResetFailedTask`, `GetAppSettings`).
|
||||
@@ -37,7 +37,7 @@ Fields:
|
||||
|---|---|---|
|
||||
| `DefaultClaudeInstructions` | text | `""` |
|
||||
| `DefaultModel` | string | `sonnet` |
|
||||
| `DefaultMaxTurns` | int | `30` |
|
||||
| `DefaultMaxTurns` | int | `100` |
|
||||
| `DefaultPermissionMode` | string | `acceptEdits` |
|
||||
| `WorktreeStrategy` | string | `sibling` |
|
||||
| `CentralWorktreeRoot` | string? | `null` |
|
||||
|
||||
162
docs/superpowers/specs/2026-04-22-agent-settings-ui-design.md
Normal file
162
docs/superpowers/specs/2026-04-22-agent-settings-ui-design.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Design: Agent settings per list and per task (UI reimplementation)
|
||||
|
||||
Date: 2026-04-22
|
||||
Status: Approved by user, implementation pending
|
||||
|
||||
## Problem
|
||||
|
||||
During the recent UI rework, the editors for per-list and per-task agent settings were lost. The data layer and Worker still support them (`TaskEntity.Model/SystemPrompt/AgentPath`, `ListConfigEntity`, `TaskRunner` + `ClaudeArgsBuilder`), but the UI has zero references to these fields. Users currently cannot set model, custom system prompt, or agent file from the app.
|
||||
|
||||
## Goal
|
||||
|
||||
Restore the ability to configure, per **list** and per **task**:
|
||||
|
||||
- `Model` — `opus` / `sonnet` / `haiku` / inherit
|
||||
- `SystemPrompt` — free-text, appended to Claude's system prompt
|
||||
- `AgentPath` — selection from agent files discovered by the Worker under `~/.todo-app/agents/*.md`
|
||||
|
||||
Per-task values override per-list values. Per-list values override global defaults from `worker.config.json`. This cascade is already implemented in `TaskRunner`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Agent file CRUD in the UI (read-only picker only)
|
||||
- `--allowedTools`, `--bare`, permission modes (deferred, matches existing worker design notes)
|
||||
- Any schema migration — the DB already has the required columns/tables
|
||||
|
||||
## Approach
|
||||
|
||||
**Update-and-broadcast over SignalR.** UI never touches the DB directly; all writes go through new `WorkerHub` methods. Worker persists via repositories, then broadcasts `ListUpdated` / `TaskUpdated` so connected clients refresh.
|
||||
|
||||
## Sections
|
||||
|
||||
### 1. Data layer additions
|
||||
|
||||
New repository `src/ClaudeDo.Data/Repositories/ListConfigRepository.cs`:
|
||||
|
||||
- `GetByListIdAsync(string listId, CancellationToken) -> ListConfigEntity?`
|
||||
- `UpsertAsync(ListConfigEntity, CancellationToken) -> ListConfigEntity`
|
||||
- `DeleteAsync(string listId, CancellationToken) -> bool`
|
||||
|
||||
New method on `ListRepository`:
|
||||
|
||||
- `UpdateAsync(ListEntity, CancellationToken)` — updates `Name`, `WorkingDir`, `DefaultCommitType`. Included because the consolidated list-settings modal edits these alongside agent fields.
|
||||
|
||||
New method on `TaskRepository`:
|
||||
|
||||
- `UpdateAgentSettingsAsync(string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken) -> bool`
|
||||
- `null` values mean "inherit" (column is nulled out in DB).
|
||||
- Kept as a narrow method to avoid widening `UpdateAsync`.
|
||||
|
||||
DI: register `ListConfigRepository` alongside other repos.
|
||||
|
||||
No migration — all columns/tables already exist.
|
||||
|
||||
### 2. SignalR hub surface
|
||||
|
||||
New DTOs in `src/ClaudeDo.Data/Dtos/` (project existing DTO pattern):
|
||||
|
||||
- `UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType)`
|
||||
- `UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath)`
|
||||
- `UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath)`
|
||||
|
||||
New methods on `WorkerHub`:
|
||||
|
||||
- `UpdateList(UpdateListDto dto)` — calls `ListRepository.UpdateAsync`, then broadcasts `ListUpdated(listId)`.
|
||||
- `UpdateListConfig(UpdateListConfigDto dto)` — upserts via `ListConfigRepository.UpsertAsync`, broadcasts `ListUpdated(listId)`. If all three fields are null, calls `DeleteAsync` instead so the row doesn't linger empty.
|
||||
- `UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)` — calls `TaskRepository.UpdateAgentSettingsAsync`, broadcasts `TaskUpdated(taskId)` (existing event).
|
||||
|
||||
New broadcast method on `HubBroadcaster`:
|
||||
|
||||
- `Task ListUpdatedAsync(string listId) => _hub.Clients.All.SendAsync("ListUpdated", listId);`
|
||||
|
||||
Loader endpoint to add:
|
||||
|
||||
- `GetListConfig(string listId)` — returns `(string? Model, string? SystemPrompt, string? AgentPath)` record, or `null` if no row. Used by `ListSettingsModal` and by `DetailsIslandViewModel` for effective-value inheritance display. Existing `GetLists` / `GetTasks` already cover the rest.
|
||||
|
||||
### 3. UI — ListSettingsModal
|
||||
|
||||
New files:
|
||||
|
||||
- `src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml` + `.axaml.cs`
|
||||
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs`
|
||||
|
||||
Entry points:
|
||||
|
||||
- **Right-click** on a list row in `ListsIslandView` → `ContextMenu` with "Settings…" item
|
||||
- **Gear button** on the list row (visible on hover/selected)
|
||||
|
||||
Layout: vertical stack with two grouped sections.
|
||||
|
||||
**General**
|
||||
- `Name` — TextBox (required, non-empty)
|
||||
- `Working directory` — TextBox + "Browse…" button (folder picker)
|
||||
- `Default commit type` — ComboBox populated with `chore, feat, fix, refactor, docs, test, ci, perf, style, build`
|
||||
|
||||
**Agent**
|
||||
- `Model` — ComboBox: `(default)`, `sonnet`, `opus`, `haiku` (selecting `(default)` sends `null`)
|
||||
- `System prompt` — multi-line TextBox with 4-row min height; empty = `null`
|
||||
- `Agent file` — ComboBox populated via `WorkerClient.GetAgentsAsync()`, first item `(none)`; tooltip shows each agent's `Description`. Empty selection = `null`.
|
||||
- `Reset agent settings` button — clears Model/SystemPrompt/AgentPath in the form (save then sends null triple → backend `DeleteAsync`).
|
||||
|
||||
Commands:
|
||||
|
||||
- `SaveCommand` — validates, calls `UpdateList` then `UpdateListConfig`, closes modal on success.
|
||||
- `CancelCommand` — closes without saving.
|
||||
|
||||
Loading: on open, ViewModel calls `GetListConfig` and populates fields; missing row means all three agent fields start empty.
|
||||
|
||||
ViewModel uses `[ObservableProperty]` / `[RelayCommand]` per project convention.
|
||||
|
||||
### 4. UI — DetailsIsland per-task agent section
|
||||
|
||||
Modify `DetailsIslandView.axaml` + `DetailsIslandViewModel.cs`.
|
||||
|
||||
Add an `Expander` titled **"Agent settings (overrides)"**, collapsed by default, below the existing task detail content.
|
||||
|
||||
Fields (same control types as ListSettingsModal's Agent section):
|
||||
|
||||
- `Model` — ComboBox prepended with `(inherit: <effective>)` option as the unset state
|
||||
- `System prompt` — TextBox with watermark showing effective inherited value when empty
|
||||
- `Agent file` — ComboBox prepended with `(inherit: <effective>)`
|
||||
|
||||
Effective-value computation:
|
||||
|
||||
- Server-side would be more accurate but requires a new hub call. For v1, UI computes locally: if task field is null, show the list's config value; if that's also null, show `(global default)`.
|
||||
- `DetailsIslandViewModel` already has access to the selected `TaskDto` + list; add list-config loading when task selection changes.
|
||||
|
||||
Persistence: auto-save on field change (debounced 300ms) calling `UpdateTaskAgentSettings`. No separate Save button — matches "settings" feel.
|
||||
|
||||
If the task is currently `Running`, fields are **read-only** (disabling controls). Agent settings only apply to the next invocation.
|
||||
|
||||
### 5. Testing
|
||||
|
||||
xUnit integration tests in `tests/ClaudeDo.Worker.Tests` against a real SQLite temp DB:
|
||||
|
||||
- `ListConfigRepositoryTests`
|
||||
- `UpsertAsync_InsertsWhenAbsent`
|
||||
- `UpsertAsync_UpdatesWhenPresent`
|
||||
- `DeleteAsync_RemovesRow`
|
||||
- `GetByListIdAsync_ReturnsNullWhenAbsent`
|
||||
- `ListRepositoryTests.UpdateAsync_UpdatesMutableFields`
|
||||
- `TaskRepositoryTests.UpdateAgentSettingsAsync_NullsClearColumns`
|
||||
- `WorkerHubTests` (if present pattern; otherwise via direct service call):
|
||||
- `UpdateListConfig_AllNull_DeletesRow`
|
||||
- `UpdateTaskAgentSettings_PersistsAndBroadcasts`
|
||||
|
||||
No UI tests — project has no UI test project. Build-time compile check is the only UI gate.
|
||||
|
||||
## Manual verification checklist
|
||||
|
||||
1. Open app, right-click a list → "Settings…" opens modal with correct current values.
|
||||
2. Change model to `opus`, save, reopen → model persists.
|
||||
3. Set system prompt on list, create task in list, run it → log confirms `--append-system-prompt` was passed.
|
||||
4. Select task, set per-task Model = `haiku`, run → log confirms `--model haiku` overrides list value.
|
||||
5. Unset per-task Model → effective falls back to list's model.
|
||||
6. Click "Reset agent settings" on list → row removed, tasks fall back to global defaults.
|
||||
7. Running task: DetailsIsland agent fields disabled.
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- **Refresh propagation**: `ListUpdated` is a new event; `IslandsShellViewModel` must subscribe and re-fetch. Any missed subscriber means stale UI. Mitigated by following the existing `TaskUpdated` pattern exactly.
|
||||
- **Working-dir browser**: Avalonia folder picker API needs a `TopLevel`; pass via `StorageProvider`. Standard pattern in Avalonia 12.
|
||||
- **Conventional-commit-type list**: hardcoded in ComboBox — acceptable, matches existing `CommitType` defaults.
|
||||
208
docs/superpowers/specs/2026-04-22-worktree-merge-design.md
Normal file
208
docs/superpowers/specs/2026-04-22-worktree-merge-design.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Worktree merge into target branch — design
|
||||
|
||||
Date: 2026-04-22
|
||||
Status: Approved (pending user review)
|
||||
|
||||
## Problem
|
||||
|
||||
`WorktreeState.Merged` exists but nothing sets it. `GitService.MergeFfOnlyAsync` exists but is unused. `DetailsIslandViewModel.ApproveMergeAsync` is a stub (`// TODO: call worker merge hub method when available`). Users have no way to merge a task's worktree back into a target branch; the only post-task options today are Discard (via Reset) or leave it Active.
|
||||
|
||||
## Goals
|
||||
|
||||
- Allow merging a task worktree's `claudedo/{id}` branch into a chosen local branch of the list's `WorkingDir`.
|
||||
- Preserve merge history via a real merge commit.
|
||||
- Never leave the target branch in a broken state.
|
||||
- Reuse existing patterns: `TaskResetService`, maintenance sweeper, dialog factory.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Remote push after merge (user does this manually).
|
||||
- Pull/fetch before merge.
|
||||
- Rebasing the task branch onto a moved target (done via Continue prompt or manually).
|
||||
- Merging across repos or handling submodules.
|
||||
- Automated UI tests (project has none).
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice |
|
||||
| --- | --- |
|
||||
| Target branch | Default to current `HEAD` branch of `WorkingDir`; user may override via dropdown. |
|
||||
| Merge strategy | Always `git merge --no-ff -m <msg> claudedo/{id}` — explicit merge commit. |
|
||||
| Post-merge cleanup | Per-merge checkbox in the dialog, default on: remove worktree dir + delete branch. |
|
||||
| Conflicts | Pre-flight guard (worktree/branch state checks); on conflict during merge, `git merge --abort` and return conflicted files to UI. |
|
||||
| UI entry points | Details island agent strip (wires existing stub) **and** DiffModal "Merge" button — both open the same modal. |
|
||||
|
||||
## Architecture
|
||||
|
||||
### New backend service
|
||||
|
||||
`src/ClaudeDo.Worker/Services/TaskMergeService.cs` — mirrors `TaskResetService`.
|
||||
|
||||
```
|
||||
public sealed class TaskMergeService
|
||||
{
|
||||
Task<MergeResult> MergeAsync(
|
||||
string taskId,
|
||||
string targetBranch,
|
||||
bool removeWorktree,
|
||||
string commitMessage,
|
||||
CancellationToken ct);
|
||||
|
||||
Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record MergeResult(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||
// Status ∈ "merged" | "conflict" | "blocked"
|
||||
|
||||
public sealed record MergeTargets(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||
```
|
||||
|
||||
Pre-flight checks (all must pass):
|
||||
1. Task exists, status not `Running`.
|
||||
2. Worktree exists, state == `Active`.
|
||||
3. `list.WorkingDir` is set and is a git repo.
|
||||
4. Target working tree is clean (`HasChangesAsync == false`).
|
||||
5. Target repo is not mid-merge (`IsMidMergeAsync == false`).
|
||||
|
||||
Failures short-circuit to `MergeResult("blocked", [], reason)` before any git write.
|
||||
|
||||
Success path:
|
||||
1. `GitService.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct)`.
|
||||
2. If `removeWorktree`:
|
||||
- `WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct)` — reuse existing method.
|
||||
- `BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct)`.
|
||||
3. `WorktreeRepository.SetStateAsync(taskId, WorktreeState.Merged, ct)`.
|
||||
4. `HubBroadcaster.BroadcastWorktreeUpdated(taskId)`.
|
||||
5. Log info; return `MergeResult("merged", [], null)`.
|
||||
|
||||
Conflict path (merge invoked, git returns non-zero with `CONFLICT` on stderr/stdout):
|
||||
1. Collect conflicted files: `git diff --name-only --diff-filter=U`.
|
||||
2. `GitService.MergeAbortAsync(list.WorkingDir, ct)`.
|
||||
3. Worktree state stays `Active`; no broadcast (nothing changed).
|
||||
4. Return `MergeResult("conflict", files, null)`.
|
||||
|
||||
### GitService additions
|
||||
|
||||
```
|
||||
Task<string> GetCurrentBranchAsync(string repoDir, CancellationToken ct); // git symbolic-ref --short HEAD
|
||||
Task<List<string>> ListLocalBranchesAsync(string repoDir, CancellationToken ct); // git branch --format=%(refname:short)
|
||||
Task MergeNoFfAsync(string repoDir, string sourceBranch, string message, CancellationToken ct); // git merge --no-ff -m <msg> <src>
|
||||
Task MergeAbortAsync(string repoDir, CancellationToken ct); // git merge --abort
|
||||
Task<bool> IsMidMergeAsync(string repoDir, CancellationToken ct); // File.Exists($"{gitDir}/MERGE_HEAD")
|
||||
Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct); // git diff --name-only --diff-filter=U
|
||||
```
|
||||
|
||||
`MergeNoFfAsync` must NOT throw on non-zero — it must return the exit code/stderr so the caller can distinguish conflict from other failures. Two ways:
|
||||
- Overload to return `(int ExitCode, string Stderr)`; or
|
||||
- Throw a dedicated `GitMergeConflictException` vs `InvalidOperationException`.
|
||||
|
||||
**Pick:** expose a tuple-returning variant for `MergeNoFfAsync` only — keeps other methods consistent, avoids exception-for-control-flow.
|
||||
|
||||
### Hub surface
|
||||
|
||||
`src/ClaudeDo.Worker/Hub/WorkerHub.cs` gains:
|
||||
|
||||
```
|
||||
Task<MergeResultDto> MergeTask(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
||||
Task<MergeTargetsDto> GetMergeTargets(string taskId);
|
||||
```
|
||||
|
||||
DTOs mirror the service records. Unexpected exceptions are re-wrapped as `HubException` (same pattern as `ResetTask`). Expected conditions (blocked, conflict) travel via the result DTO, not exceptions.
|
||||
|
||||
### WorkerClient
|
||||
|
||||
`src/ClaudeDo.Ui/Services/WorkerClient.cs`:
|
||||
|
||||
```
|
||||
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
||||
Task<MergeTargetsDto> GetMergeTargetsAsync(string taskId);
|
||||
```
|
||||
|
||||
### UI
|
||||
|
||||
**New modal:** `src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml` + `MergeModalViewModel.cs`.
|
||||
|
||||
Dialog fields:
|
||||
- **Target branch** combobox (source: `GetMergeTargetsAsync.LocalBranches`; default: `DefaultBranch`).
|
||||
- **Remove worktree after merge** checkbox (default: checked).
|
||||
- **Commit message** text field (default: `Merge task: {Task.Title}`).
|
||||
- OK / Cancel buttons.
|
||||
|
||||
Post-submit UI states (rendered inside the modal, not a second dialog):
|
||||
- `merged` → brief success line, modal closes after 1–2s; parent refreshes.
|
||||
- `conflict` → red inline panel listing files; OK button hidden, only Close remains.
|
||||
- `blocked` → orange inline panel with the reason; OK button hidden, only Close remains.
|
||||
|
||||
**Wiring:**
|
||||
- `DetailsIslandViewModel.ApproveMergeAsync` opens `MergeModalView` (factory injected through `MainWindowViewModel`'s existing dialog pattern).
|
||||
- `DiffModalView` gains a Merge button in its command strip; click opens the same modal with the current task's id.
|
||||
- Both entry points are only visible/enabled when `Task.Worktree?.State == Active` (same predicate as the existing Reset/Continue visibility logic — extend `ShowFailedActions`-style gating with a new flag `CanMerge`).
|
||||
|
||||
`MergeModalViewModel` depends only on `WorkerClient`. It does not touch `GitService` directly — all git access stays worker-side.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
User clicks Merge (Details island or DiffModal)
|
||||
→ DetailsIslandViewModel / DiffModalViewModel opens MergeModalView
|
||||
→ MergeModalViewModel.InitializeAsync
|
||||
→ WorkerClient.GetMergeTargetsAsync(taskId)
|
||||
→ Hub.GetMergeTargets
|
||||
→ TaskMergeService.GetTargetsAsync
|
||||
→ GitService.GetCurrentBranchAsync + ListLocalBranchesAsync
|
||||
→ Combobox populated, default selected
|
||||
User edits fields, clicks OK
|
||||
→ WorkerClient.MergeTaskAsync(taskId, branch, remove, msg)
|
||||
→ Hub.MergeTask
|
||||
→ TaskMergeService.MergeAsync
|
||||
→ pre-flight checks
|
||||
→ GitService.MergeNoFfAsync → (success | conflict)
|
||||
→ on success: optional remove + branch delete, SetState(Merged), broadcast
|
||||
→ on conflict: MergeAbortAsync, return conflict DTO
|
||||
→ MergeModalViewModel renders result
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
| Case | Surfaced as |
|
||||
| --- | --- |
|
||||
| Task running | `MergeResult("blocked", [], "task is running")` |
|
||||
| Worktree not Active | `("blocked", [], "worktree state is {state}")` |
|
||||
| Working dir dirty | `("blocked", [], "target branch has uncommitted changes")` |
|
||||
| Target mid-merge | `("blocked", [], "target branch is mid-merge")` |
|
||||
| `list.WorkingDir` null | `("blocked", [], "list has no working directory")` |
|
||||
| Merge conflict | `("conflict", [files], null)` — target auto-restored |
|
||||
| Unknown git failure | `HubException` with stderr |
|
||||
| Post-merge cleanup fails | Log a warning; merge already succeeded, state already `Merged`. Return `("merged", [], "cleanup: {reason}")` — `Status=="merged"` with a non-null `ErrorMessage` means the merge went through but the worktree couldn't be removed. UI surfaces this as a yellow note, not a failure. |
|
||||
|
||||
## Testing
|
||||
|
||||
`tests/ClaudeDo.Worker.Tests/TaskMergeServiceTests.cs` (real SQLite + real git, matching existing test conventions):
|
||||
|
||||
1. Happy path, ff-able history → one merge commit, state Merged, broadcast fired.
|
||||
2. Happy path, diverged non-conflicting → merge commit created.
|
||||
3. Conflict path → conflicted files returned, target branch HEAD matches pre-merge, `MERGE_HEAD` absent, worktree state still Active.
|
||||
4. Pre-flight: worktree Merged/Discarded → blocked.
|
||||
5. Pre-flight: dirty working tree → blocked.
|
||||
6. Pre-flight: mid-merge target → blocked.
|
||||
7. `removeWorktree=true` → worktree dir gone, branch deleted, state Merged.
|
||||
8. `removeWorktree=false` → worktree + branch survive, state Merged.
|
||||
9. Task Running → blocked.
|
||||
|
||||
`tests/ClaudeDo.Worker.Tests/GitServiceMergeTests.cs` (narrow tests for new GitService methods): `MergeNoFfAsync` success/conflict tuple semantics, `MergeAbortAsync` clears MERGE_HEAD, `IsMidMergeAsync` true/false, `ListLocalBranchesAsync` returns expected set, `GetCurrentBranchAsync` on fresh repo.
|
||||
|
||||
Manual UI checklist captured in the implementation plan, not automated.
|
||||
|
||||
## Implementation order (sketch)
|
||||
|
||||
1. GitService additions + their tests.
|
||||
2. `TaskMergeService` + its tests (hub/UI not yet wired).
|
||||
3. Hub methods + `WorkerClient` methods.
|
||||
4. `MergeModalView` + `MergeModalViewModel`.
|
||||
5. Wire `DetailsIslandViewModel.ApproveMergeAsync`.
|
||||
6. Wire DiffModal Merge button.
|
||||
7. Manual UI walkthrough against the checklist.
|
||||
|
||||
## Open items
|
||||
|
||||
None.
|
||||
177
docs/superpowers/specs/2026-04-23-default-agents-design.md
Normal file
177
docs/superpowers/specs/2026-04-23-default-agents-design.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Default Agents — Design
|
||||
|
||||
**Date:** 2026-04-23
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Ship ClaudeDo with a curated set of default agents so that users have useful agents available on first launch, without losing the file-based ownership model (user-editable, user-deletable). Provide a "Restore defaults" action to recover missing defaults on demand.
|
||||
|
||||
## Agents to Ship
|
||||
|
||||
Six markdown agents covering the common stages of task execution plus one general-purpose agent:
|
||||
|
||||
| File | Focus |
|
||||
|---|---|
|
||||
| `code-reviewer.md` | Review diff for bugs, logic errors, convention adherence. Flags only high-confidence issues. |
|
||||
| `test-writer.md` | Generate unit/integration tests for changed code. Follows existing test patterns. |
|
||||
| `debugger.md` | Systematic root-cause analysis — reproduce, isolate, hypothesize, verify. |
|
||||
| `security-reviewer.md` | OWASP-style audit focused on auth, SQL injection, input handling, secret exposure. |
|
||||
| `explorer.md` | Fast codebase navigation and answering "where/how" questions. Terse output. |
|
||||
| `researcher.md` | General-purpose research, doc summarization, analysis, investigation. Non-code. |
|
||||
|
||||
Each file uses Claude Code's standard agent frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: <agent name>
|
||||
description: <one-line description>
|
||||
---
|
||||
|
||||
<system prompt body>
|
||||
```
|
||||
|
||||
Content target: ~20–40 lines per file. Style matches the existing Claude Code agent conventions.
|
||||
|
||||
## Behavior
|
||||
|
||||
**Seed on first launch, restore on demand:**
|
||||
|
||||
1. Bundled agents live alongside the Worker binary at `<AppContext.BaseDirectory>/DefaultAgents/*.md`.
|
||||
2. At Worker startup, for each bundled file: if `~/.todo-app/agents/<name>.md` does NOT exist, copy it in. If it exists, leave it alone — the user owns their copy.
|
||||
3. A "Restore default agents" button in the settings modal re-runs the same check, restoring any that the user has deleted.
|
||||
|
||||
The seed path and the restore path are the same code — only the invocation differs (startup vs. hub call).
|
||||
|
||||
## Components
|
||||
|
||||
### `DefaultAgents/*.md` (bundled content)
|
||||
|
||||
Location: `src/ClaudeDo.Worker/DefaultAgents/`
|
||||
Packaging: `<Content Include="DefaultAgents\*.md" CopyToOutputDirectory="PreserveNewest" />` in `ClaudeDo.Worker.csproj`.
|
||||
At runtime they land at `<AppContext.BaseDirectory>/DefaultAgents/*.md` next to the executable.
|
||||
|
||||
### `DefaultAgentSeeder` (new service)
|
||||
|
||||
Location: `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
|
||||
|
||||
```
|
||||
public sealed class DefaultAgentSeeder
|
||||
{
|
||||
public DefaultAgentSeeder(string bundleDir, string targetDir);
|
||||
public Task<SeedResult> SeedMissingAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record SeedResult(int Copied, int Skipped);
|
||||
```
|
||||
|
||||
Behavior of `SeedMissingAsync`:
|
||||
- If `bundleDir` doesn't exist, log warning and return `(0, 0)`.
|
||||
- Enumerate `*.md` in `bundleDir`.
|
||||
- For each file, if the target path (`targetDir/<filename>`) is missing, copy it; else increment `Skipped`.
|
||||
- Create `targetDir` if missing (consistent with existing `AgentFileService.WriteAsync`).
|
||||
- Per-file exceptions are caught and logged; the seeder continues with the next file. The method itself does not throw for individual file failures.
|
||||
|
||||
### `Program.cs` wiring
|
||||
|
||||
After `AgentFileService` registration and before `app.Run()`:
|
||||
|
||||
```csharp
|
||||
var bundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
|
||||
var seeder = new DefaultAgentSeeder(bundleDir, agentsDir);
|
||||
await seeder.SeedMissingAsync();
|
||||
builder.Services.AddSingleton(seeder);
|
||||
```
|
||||
|
||||
The seeder is also registered as a singleton so the hub can invoke it for the restore flow.
|
||||
|
||||
### `WorkerHub.RestoreDefaultAgents`
|
||||
|
||||
Location: add method to existing `src/ClaudeDo.Worker/Hub/WorkerHub.cs`.
|
||||
|
||||
Signature: `public async Task<SeedResult> RestoreDefaultAgents()`
|
||||
|
||||
Behavior:
|
||||
- Calls `DefaultAgentSeeder.SeedMissingAsync()`.
|
||||
- Returns the `SeedResult` to the caller.
|
||||
- No separate broadcast — the UI will call `GetAgents` after the restore returns, reusing the existing refresh path.
|
||||
|
||||
### UI — Settings Modal
|
||||
|
||||
Location: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` + `SettingsModalViewModel`.
|
||||
|
||||
Add a "Restore default agents" button to the modal. On click:
|
||||
1. Disable the button, show a spinner label.
|
||||
2. Call `WorkerClient.RestoreDefaultAgentsAsync()`.
|
||||
3. Show a brief inline confirmation: `"Restored {Copied} agent(s)"` or `"All defaults already present"`.
|
||||
4. Trigger the existing agent list refresh so the new files appear immediately in the rest of the UI.
|
||||
|
||||
### `WorkerClient` method
|
||||
|
||||
Add `Task<SeedResult> RestoreDefaultAgentsAsync(CancellationToken ct = default)` to `WorkerClient` — thin wrapper that invokes the hub method.
|
||||
|
||||
## Data Flow
|
||||
|
||||
**Startup:**
|
||||
```
|
||||
Worker starts → DefaultAgentSeeder.SeedMissingAsync()
|
||||
→ copies missing files into ~/.todo-app/agents/
|
||||
AgentFileService.ScanAsync() (on first GetAgents call) → sees the seeded files
|
||||
```
|
||||
|
||||
**User restores:**
|
||||
```
|
||||
Settings modal button click
|
||||
→ WorkerClient.RestoreDefaultAgentsAsync()
|
||||
→ WorkerHub.RestoreDefaultAgents()
|
||||
→ DefaultAgentSeeder.SeedMissingAsync()
|
||||
→ returns SeedResult(copied, skipped)
|
||||
UI shows confirmation, triggers GetAgents refresh
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Failure | Behavior |
|
||||
|---|---|
|
||||
| Missing `DefaultAgents/` bundle dir | Log warning, return `(0, 0)`. Startup proceeds. |
|
||||
| Individual file copy failure (disk, permissions) | Catch per-file, log, continue with the remaining files. |
|
||||
| Corrupt bundled markdown (no valid frontmatter) | Copied anyway — the `AgentFileService` frontmatter parser already falls back to filename-as-name. |
|
||||
| Startup seeder exception (unexpected) | Log as warning, do not crash the Worker. Agents can still be restored via the button. |
|
||||
| Hub `RestoreDefaultAgents` exception | Propagate to client as SignalR error; UI shows a generic "Restore failed" message. |
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit:** `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
|
||||
|
||||
- Seeds all files when target dir is empty.
|
||||
- Skips files that already exist.
|
||||
- Preserves existing user-modified files (file mtime / content unchanged).
|
||||
- Returns accurate `SeedResult` counts.
|
||||
- Handles missing bundle dir gracefully (returns `(0, 0)`, no throw).
|
||||
- Creates target dir if it doesn't exist.
|
||||
|
||||
**Integration:** extend `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`
|
||||
|
||||
- `RestoreDefaultAgents` invokes the seeder and returns the count.
|
||||
|
||||
**No UI tests.** The project has no UI test harness; settings modal behavior is exercised manually.
|
||||
|
||||
## Build / Packaging
|
||||
|
||||
`src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` gains:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<Content Include="DefaultAgents\*.md">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
No new NuGet dependencies.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Editing bundled agents in-place (user edits their copy under `~/.todo-app/agents/`; bundle is read-only by convention).
|
||||
- Versioning / updating user copies when the bundled version changes. If a bundled agent is improved in a later release, the user's copy is not overwritten. A future release may add a "diff / reset to bundled" flow, but not now.
|
||||
- Packaging as embedded resources. Content files copied to output are simpler, inspectable on disk, and consistent with the file-based agent model.
|
||||
468
docs/superpowers/specs/2026-04-23-planning-sessions-design.md
Normal file
468
docs/superpowers/specs/2026-04-23-planning-sessions-design.md
Normal file
@@ -0,0 +1,468 @@
|
||||
# Planning Sessions — Design
|
||||
|
||||
**Status:** Approved for implementation
|
||||
**Date:** 2026-04-23
|
||||
**Scope:** Feature — "Open planning Session" context menu on tasks that spawns an interactive Windows Terminal with Claude (Sonnet 4.6, medium thinking) and a scoped MCP server, letting the user brainstorm and have Claude break a rough task into concrete executable child-tasks.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Allow a user to take a vague task (a title plus some TODO-style notes) and convert it — via interactive dialogue with Claude in a terminal — into a structured set of concrete, executable child-tasks that the worker queue can pick up and run.
|
||||
|
||||
The interaction is driven by Claude calling MCP tools against a scoped server running inside the existing `ClaudeDo.Worker` process. The parent task becomes a "Planning" container that holds its children as a flat (single-level) hierarchy.
|
||||
|
||||
---
|
||||
|
||||
## 2. Status Flow
|
||||
|
||||
**Parent (new statuses `Planning`, `Planned`):**
|
||||
|
||||
```
|
||||
Manual ──[Open planning Session]──▶ Planning ──[finalize]──▶ Planned
|
||||
│ │
|
||||
│ (all children reach terminal state)
|
||||
│ ▼
|
||||
│ Done
|
||||
│ or
|
||||
│ Failed (if any child Failed)
|
||||
▼
|
||||
[Discard] ──▶ Manual
|
||||
```
|
||||
|
||||
**Child (new status `Draft`):**
|
||||
|
||||
```
|
||||
Draft ──[finalize]──▶ Manual | Queued (if "agent" tag) ──▶ Running ──▶ Done | Failed
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Parent with status `Planning` or `Planned` is **never** picked up by the queue.
|
||||
- Children with status `Draft` are **never** picked up by the queue.
|
||||
- Hierarchy is strictly **one level deep**: a child task cannot itself become a planning parent (enforced app-side: Plan menu item hidden/disabled if `ParentTaskId IS NOT NULL`).
|
||||
- One planning session per parent task at a time (`StartPlanningSessionAsync` errors if parent is already `Planning`; use Resume instead).
|
||||
- Parent auto-status on child completion (evaluated after any child reaches `Done` or `Failed`):
|
||||
- At least one child `Failed` and no children still in non-terminal states → Parent `Failed`.
|
||||
- All children `Done` → Parent `Done`.
|
||||
- Any child still `Manual`/`Queued`/`Running`/`Draft` → Parent stays `Planned`.
|
||||
- Worktree state (`Merged`/`Discarded`/`Kept`) is orthogonal; only `Task.Status` determines completion.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
### 3.1 Schema changes to `Tasks` table
|
||||
|
||||
| Column | Type | Nullable | Purpose |
|
||||
|---|---|---|---|
|
||||
| `ParentTaskId` | `string` (FK → `Tasks.Id`, `DeleteBehavior.Restrict`) | yes | When set, row is a child of a planning parent. NULL = top-level task. |
|
||||
| `PlanningSessionId` | `string` | yes | Claude CLI session ID captured after first run; used with `--resume`. Only set on planning parents. |
|
||||
| `PlanningSessionToken` | `string` | yes | Random 32-byte Base64 token generated per session; acts as bearer for MCP calls. NULL when no active session. |
|
||||
| `PlanningFinalizedAt` | `DateTime` | yes | Timestamp when `finalize` was called. NULL until finalized. |
|
||||
|
||||
Index: `(ParentTaskId)` for fast children lookup.
|
||||
|
||||
### 3.2 Status enum additions
|
||||
|
||||
`ClaudeDo.Data.Models.TaskStatus` gains:
|
||||
- `Planning` — parent, session active or paused, drafts may exist.
|
||||
- `Planned` — parent, finalized, children are real tasks (may still be running).
|
||||
- `Draft` — child, created during session, not yet finalized.
|
||||
|
||||
Existing values unchanged: `Manual | Queued | Running | Done | Failed`. Persisted via `ValueConverter` to string (existing convention — confirmed via `TaskEntity.cs`).
|
||||
|
||||
### 3.3 Navigation properties
|
||||
|
||||
On `TaskEntity`:
|
||||
```csharp
|
||||
public string? ParentTaskId { get; set; }
|
||||
public TaskEntity? Parent { get; set; }
|
||||
public ICollection<TaskEntity> Children { get; set; } = new List<TaskEntity>();
|
||||
public string? PlanningSessionId { get; set; }
|
||||
public string? PlanningSessionToken { get; set; }
|
||||
public DateTime? PlanningFinalizedAt { get; set; }
|
||||
```
|
||||
|
||||
In `TaskEntityConfiguration`:
|
||||
```csharp
|
||||
.HasOne(t => t.Parent)
|
||||
.WithMany(t => t.Children)
|
||||
.HasForeignKey(t => t.ParentTaskId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
```
|
||||
|
||||
**Rationale for `Restrict`:** cascade delete would orphan worktrees of in-flight child tasks. UI must handle the `DbUpdateException` and prompt the user to discard children first.
|
||||
|
||||
### 3.4 Repository additions
|
||||
|
||||
`ITaskRepository` gains:
|
||||
- `Task<IReadOnlyList<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct)`
|
||||
- `Task<TaskEntity> CreateChildAsync(string parentId, string title, string? description, IReadOnlyList<string>? tagNames, string? commitType, CancellationToken ct)` — creates with `Status = Draft`, `ParentTaskId = parentId`.
|
||||
- `Task<int> FinalizePlanningAsync(string parentId, bool queueAgentTasks, CancellationToken ct)` — transactional: all Drafts → `Manual` (or `Queued` if tagged "agent" and `queueAgentTasks=true`), parent → `Planned`, set `PlanningFinalizedAt`, clear `PlanningSessionToken`. Returns count of finalized children.
|
||||
- `Task<bool> DiscardPlanningAsync(string parentId, CancellationToken ct)` — deletes all Drafts, parent → `Manual`, clears `PlanningSessionId/Token/FinalizedAt`.
|
||||
- `Task<TaskEntity?> SetPlanningStartedAsync(string taskId, string sessionToken, CancellationToken ct)` — sets parent `Status = Planning`, stores token; returns null if parent not in `Manual` state.
|
||||
- `Task UpdatePlanningSessionIdAsync(string parentId, string sessionId, CancellationToken ct)` — captures Claude CLI session ID after launch.
|
||||
- `Task<TaskEntity?> FindByPlanningTokenAsync(string token, CancellationToken ct)` — used by MCP auth handler.
|
||||
|
||||
`GetNextQueuedAgentTaskAsync` — verify the existing query filters on `Status = Queued`; no additional filter needed since Planning/Planned/Draft are different statuses. Add explicit regression test.
|
||||
|
||||
### 3.5 Auto-status hook
|
||||
|
||||
After every `MarkDoneAsync`/`MarkFailedAsync` on a task with `ParentTaskId != null`, check parent children. If all in terminal state:
|
||||
- Any `Failed` → parent `Failed` with `FinishedAt = now()`.
|
||||
- All `Done` (or worktrees `Discarded`) → parent `Done` with `FinishedAt = now()`.
|
||||
|
||||
Implemented as a private helper `TryCompleteParentAsync(string parentId, CancellationToken ct)` called at the end of the two Mark methods.
|
||||
|
||||
### 3.6 Migration
|
||||
|
||||
`dotnet ef migrations add AddPlanningSupport` — adds four columns and the `(ParentTaskId)` index. No data migration needed (new columns all nullable).
|
||||
|
||||
---
|
||||
|
||||
## 4. MCP Server Surface
|
||||
|
||||
### 4.1 Transport
|
||||
|
||||
**HTTP (streamable) inside the existing Worker Kestrel host.** Mount on `/mcp` alongside the existing SignalR hub at `127.0.0.1:47821`. No separate process, no stdio proxy.
|
||||
|
||||
Library: `ModelContextProtocol` (official C# MCP SDK).
|
||||
|
||||
### 4.2 Authentication
|
||||
|
||||
Per-session bearer token:
|
||||
1. `StartPlanningSessionAsync` generates a 32-byte random token, persists to `Tasks.PlanningSessionToken`.
|
||||
2. Token is written into the session's `mcp.json` as `Authorization: Bearer <token>`.
|
||||
3. Every MCP request passes through an auth filter that looks up the token via `FindByPlanningTokenAsync`. If found, the parent task ID is stored in the request context. If not, 401.
|
||||
4. Token is invalidated (set NULL) on `finalize` or `discard`.
|
||||
|
||||
### 4.3 Tools
|
||||
|
||||
All tools are scoped to the parent task resolved from the request's token. `parent_id` is never an argument.
|
||||
|
||||
| Tool | Params | Returns | Effect |
|
||||
|---|---|---|---|
|
||||
| `create_child_task` | `title: string`, `description?: string`, `tags?: string[]`, `commit_type?: string` | `{ task_id, status: "Draft" }` | Creates a Draft child under this session's parent. |
|
||||
| `list_child_tasks` | — | `[{ task_id, title, description, status, tags }]` | Lists children of this parent (in session context, always Drafts). |
|
||||
| `update_child_task` | `task_id: string`, optional: `title`, `description`, `tags`, `commit_type` | `{ task }` | Errors if target is not a Draft or not a child of this parent. |
|
||||
| `delete_child_task` | `task_id: string` | `{ ok: true }` | Errors if target is not a Draft or not a child of this parent. |
|
||||
| `update_planning_task` | `title?: string`, `description?: string` | `{ task }` | Only title/description on the parent itself. |
|
||||
| `finalize` | `queue_agent_tasks?: bool = true` | `{ finalized_count: int }` | Calls `FinalizePlanningAsync`. Token invalidated. |
|
||||
|
||||
### 4.4 Real-time UI
|
||||
|
||||
After each successful tool call, the MCP handler fires a `TaskUpdated` event on the Worker's SignalR hub. The UI subscribes as it already does; drafts appear/update live in the tasks list while the user chats with Claude in the terminal.
|
||||
|
||||
### 4.5 Errors
|
||||
|
||||
- 401 for missing/invalid token.
|
||||
- MCP error `-32602` "task not found or not a child of this planning session" for cross-parent access attempts.
|
||||
- MCP error `-32602` "cannot modify finalized task" for `update/delete` on non-Draft.
|
||||
- Token validation short-circuits before tool dispatch.
|
||||
|
||||
---
|
||||
|
||||
## 5. Terminal Launch & Claude CLI Invocation
|
||||
|
||||
### 5.1 Launcher service
|
||||
|
||||
New interface `IPlanningTerminalLauncher` in the UI or App layer:
|
||||
```csharp
|
||||
Task LaunchAsync(PlanningSessionStart info, CancellationToken ct);
|
||||
Task LaunchResumeAsync(PlanningSessionResume info, CancellationToken ct);
|
||||
```
|
||||
|
||||
`PlanningSessionStart` contains: `WorkingDir`, `McpConfigPath`, `InitialPromptPath`, `SystemPromptPath`.
|
||||
`PlanningSessionResume` contains: `WorkingDir`, `McpConfigPath`, `ClaudeSessionId`.
|
||||
|
||||
### 5.2 Per-session files
|
||||
|
||||
Path: `~/.todo-app/planning-sessions/<parentTaskId>/`
|
||||
- `mcp.json` — MCP config referencing the HTTP endpoint with bearer token.
|
||||
- `system-prompt.md` — planning-mode system prompt (append, not replace).
|
||||
- `initial-prompt.txt` — first user-visible message (title + description + short instructions).
|
||||
|
||||
Cleanup:
|
||||
- `Discard` → remove directory.
|
||||
- `Finalize` → keep directory (for audit; prune on app start if older than N days, optional).
|
||||
|
||||
### 5.3 `mcp.json` format
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claudedo": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:47821/mcp",
|
||||
"headers": { "Authorization": "Bearer <PlanningSessionToken>" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Claude CLI invocation (new session)
|
||||
|
||||
```
|
||||
wt.exe -d "<list.WorkingDir>" cmd /k ^
|
||||
claude ^
|
||||
--model claude-sonnet-4-6 ^
|
||||
--append-system-prompt "<contents of system-prompt.md>" ^
|
||||
--mcp-config "<mcp.json absolute path>" ^
|
||||
--allowedTools "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill" ^
|
||||
"<contents of initial-prompt.txt>"
|
||||
```
|
||||
|
||||
### 5.5 Claude CLI invocation (resume)
|
||||
|
||||
```
|
||||
wt.exe -d "<list.WorkingDir>" cmd /k ^
|
||||
claude --resume <PlanningSessionId> --mcp-config "<mcp.json>"
|
||||
```
|
||||
Resume inherits model, system prompt, and allowed tools from the original session.
|
||||
|
||||
### 5.6 System prompt (draft, refined in Plan B)
|
||||
|
||||
> You are in a ClaudeDo planning session for a task. Your job is to brainstorm with the user, then break their rough intent into concrete, independently-executable child-tasks. Each child-task should be something a single automated agent can pick up and complete autonomously. Use the `mcp__claudedo__*` tools to create/update/delete drafts in real time. You may read the repository for context (Read/Grep/Glob) but must not modify any files. When the user is satisfied, call `finalize`. Skills you may find useful: `superpowers:writing-plans`, `superpowers:writing-clearly-and-concisely`.
|
||||
|
||||
### 5.7 Initial prompt (template)
|
||||
|
||||
```
|
||||
<Parent task title>
|
||||
|
||||
<Parent task description, if any>
|
||||
|
||||
---
|
||||
We're planning this task together. Brainstorm with me, then create concrete child-tasks via the MCP tools. I'll call `finalize` when we're done.
|
||||
```
|
||||
|
||||
### 5.8 Unknowns to resolve during Plan B implementation
|
||||
|
||||
These are left **open** in this spec; they'll be pinned down during implementation via `mcp__plugin_context7_context7__query-docs` for the Claude Code CLI:
|
||||
|
||||
1. Exact flag for thinking budget (`--thinking-budget medium`? model suffix `claude-sonnet-4-6-thinking`? something else?).
|
||||
2. Exact casing of tool names in `--allowedTools` (`Read`/`read`, `WebFetch`/`web_fetch`, `Skill`).
|
||||
3. Whether `--append-system-prompt` accepts a file reference (`@path`) or requires inline string.
|
||||
4. Whether Claude CLI supports a `--session-id` flag for pre-assigning the session ID, or whether we must read it back from `~/.claude/projects/<hash>/sessions/` after the process starts.
|
||||
|
||||
If (4) resolves to "read back", strategy:
|
||||
- Poll `~/.claude/projects/<hash>/sessions/` directory modtimes shortly after launch; newest session file after launch timestamp is ours.
|
||||
- Cache the result on the parent task via `UpdatePlanningSessionIdAsync`.
|
||||
- If session ID can't be captured, Resume falls back to `claude --continue` (last session in that project).
|
||||
|
||||
### 5.9 Pre-flight checks
|
||||
|
||||
On `LaunchAsync`:
|
||||
- `wt.exe` resolvable in PATH → else throw `PlanningLaunchException("Windows Terminal not found")`, UI shows install hint.
|
||||
- `claude` resolvable in PATH → else `PlanningLaunchException("Claude CLI not installed")`.
|
||||
- `list.WorkingDir` exists → else `PlanningLaunchException("Working directory not found: <path>")`.
|
||||
|
||||
---
|
||||
|
||||
## 6. UI Changes
|
||||
|
||||
### 6.1 Context menu (`TaskRowView.axaml`)
|
||||
|
||||
New entries, conditional on status:
|
||||
- `Manual` + `ParentTaskId IS NULL` → **"Open planning Session"**
|
||||
- `Planning` → **"Resume planning Session"** and **"Discard planning session"**
|
||||
- `Planned` / `Done` / `Failed` (parent) → no planning-related entries (7c: no re-planning).
|
||||
- Children (`ParentTaskId IS NOT NULL`) → never show planning entries.
|
||||
|
||||
### 6.2 Hierarchy rendering (`TasksIslandView.axaml`)
|
||||
|
||||
Approach: **flat stream with indentation**, not a `TreeView`.
|
||||
|
||||
- `TasksIslandViewModel` builds `OpenItems`/`CompletedItems`/etc. as flat `ObservableCollection<TaskRowViewModel>` with parents followed by their children if expanded.
|
||||
- `TaskRowViewModel` gets `IsChild: bool` and `IsPlanningParent: bool` and `IsExpanded: bool`.
|
||||
- `TaskRowView` indents 24px when `IsChild`, shows a thin left border in `TextFaintBrush`.
|
||||
- Parents with `IsPlanningParent` render a chevron (▸/▾) that toggles `IsExpanded`; collapsed parents hide their children from the flat stream.
|
||||
- Expanded-state map kept in the VM (`Dictionary<string, bool>`, default `true`).
|
||||
|
||||
### 6.3 Draft and planning styling (`TaskRowView`)
|
||||
|
||||
- `Status = Draft` → row italic, 70% opacity, small left-aligned badge "DRAFT".
|
||||
- Parent `Status = Planning` → badge "PLANNING" (accent: warning-amber).
|
||||
- Parent `Status = Planned` → badge "PLANNED" (accent: neutral-blue).
|
||||
|
||||
### 6.4 Unfinished-session dialog
|
||||
|
||||
Trigger: on app start **and** on any context-menu click against a `Planning` parent.
|
||||
|
||||
Modal (built with existing `TaskCompletionSource<T>` dialog pattern):
|
||||
|
||||
```
|
||||
Unfinished planning session
|
||||
"<Parent title>"
|
||||
<N> draft tasks waiting to be finalized.
|
||||
|
||||
[Resume] [Finalize now] [Discard]
|
||||
```
|
||||
|
||||
- Resume → `ResumePlanningSessionAsync`, opens terminal with `--resume`.
|
||||
- Finalize now → `FinalizePlanningSessionAsync` (server-side, no terminal). Useful when the user is confident drafts are good.
|
||||
- Discard → `DiscardPlanningSessionAsync`.
|
||||
|
||||
### 6.5 TasksIslandViewModel commands
|
||||
|
||||
- `[RelayCommand] OpenPlanningSessionAsync(TaskRowViewModel? row)`
|
||||
- `[RelayCommand] ResumePlanningSessionAsync(TaskRowViewModel? row)`
|
||||
- `[RelayCommand] DiscardPlanningSessionAsync(TaskRowViewModel? row)`
|
||||
- `[RelayCommand] FinalizePlanningSessionAsync(TaskRowViewModel? row)`
|
||||
- `[RelayCommand] ToggleExpand(TaskRowViewModel parentRow)`
|
||||
|
||||
### 6.6 WorkerClient additions (`ClaudeDo.Ui/Services/WorkerClient.cs`)
|
||||
|
||||
- `Task<PlanningSessionLaunchInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct)` — returns `{ WorkingDir, McpConfigPath, InitialPromptPath, SystemPromptPath }`.
|
||||
- `Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct)` — returns `{ WorkingDir, McpConfigPath, ClaudeSessionId }`.
|
||||
- `Task<int> FinalizePlanningSessionAsync(string taskId, CancellationToken ct)` — returns finalized count.
|
||||
- `Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct)`.
|
||||
- `Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct)` — for the unfinished-session dialog.
|
||||
|
||||
Existing `TaskUpdated` event covers live draft updates; no new event needed.
|
||||
|
||||
### 6.7 Delete handling
|
||||
|
||||
When the user tries to delete a parent with children:
|
||||
- Repository throws `DbUpdateException` (FK Restrict).
|
||||
- UI catches, shows: "This task has N child tasks. Discard drafts and delete? / Delete all including children? / Cancel."
|
||||
- "Delete all including children" → UI iterates children and deletes them first, then the parent.
|
||||
- "Discard drafts" option only appears if parent status is `Planning` (drafts exist to discard).
|
||||
|
||||
---
|
||||
|
||||
## 7. Lifecycle & Error Handling
|
||||
|
||||
### 7.1 Worker queue isolation
|
||||
|
||||
`GetNextQueuedAgentTaskAsync` filters on `Status = Queued` — Planning/Planned/Draft are excluded by status. Add explicit regression test to lock this in.
|
||||
|
||||
### 7.2 Parent auto-completion (repeat of 2, for implementation reference)
|
||||
|
||||
After `MarkDoneAsync`/`MarkFailedAsync`:
|
||||
```csharp
|
||||
if (task.ParentTaskId is not null)
|
||||
await TryCompleteParentAsync(task.ParentTaskId, ct);
|
||||
```
|
||||
where `TryCompleteParentAsync` loads children, checks terminal status, sets parent accordingly.
|
||||
|
||||
### 7.3 Session-start errors
|
||||
|
||||
Table in §5.9 Pre-flight checks. UI receives typed exceptions, shows appropriate dialog.
|
||||
|
||||
### 7.4 Session-runtime errors
|
||||
|
||||
- Terminal crashes → drafts + token persist. Resume via dialog (§6.4).
|
||||
- Worker restart → drafts + token persist. Resume rebuilds HTTP connection.
|
||||
- MCP call fails transiently → Claude CLI retries or the model reports the error to the user in terminal; drafts remain in whatever state the last successful call left them.
|
||||
- No session timeout — brainstorming may be long.
|
||||
|
||||
### 7.5 Concurrency
|
||||
|
||||
- Different parents → independent sessions, one token per parent.
|
||||
- Same parent launched twice → `StartPlanningSessionAsync` throws; UI says "Already planning; use Resume".
|
||||
- Cleanup on app exit: nothing — planning state is fully persisted in DB and files.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### 8.1 Automated (in `ClaudeDo.Worker.Tests`)
|
||||
|
||||
**Schema & repository:**
|
||||
- Migration applies cleanly on fresh DB.
|
||||
- `GetChildrenAsync` returns only direct children, sorted.
|
||||
- `CreateChildAsync` sets Status=Draft, ParentTaskId correctly.
|
||||
- `FinalizePlanningAsync` transactionally transitions drafts to Manual/Queued, sets parent to Planned, sets timestamp, clears token. On simulated DB error, rolls back fully.
|
||||
- `DiscardPlanningAsync` removes drafts, resets parent.
|
||||
- `GetNextQueuedAgentTaskAsync` ignores Drafts, Planning parents, Planned parents.
|
||||
- `Restrict` cascade: delete parent with children throws `DbUpdateException`.
|
||||
|
||||
**Auto-status hook (§7.2):**
|
||||
- All children Done → parent Done.
|
||||
- Mix: some Done, at least one Failed, rest in terminal state → parent Failed.
|
||||
- Mix with one still Running → parent stays Planned.
|
||||
- Parent stays Planned while any Draft exists (defensive — finalize should have cleared them).
|
||||
|
||||
**MCP handlers (against SQLite + in-process HTTP):**
|
||||
- Valid token → tool executes.
|
||||
- Missing/invalid token → 401.
|
||||
- `create_child_task` → creates Draft, emits TaskUpdated event.
|
||||
- `update_child_task` on non-Draft → MCP error.
|
||||
- `delete_child_task` on non-Draft → MCP error.
|
||||
- `finalize` called twice: first succeeds, second errors because token is invalidated.
|
||||
- Cross-parent access: tool with `task_id` belonging to another parent's session → MCP error.
|
||||
|
||||
**SignalR endpoints (integration with Worker host):**
|
||||
- Start → token generated, session directory + files created, `mcp.json` contains token.
|
||||
- Start on already-`Planning` parent → error.
|
||||
- Resume → no new token, reads `PlanningSessionId` from DB.
|
||||
- Discard → drafts gone, directory removed, token NULL, parent back to Manual.
|
||||
|
||||
### 8.2 Manual (added to `docs/open.md` checklist)
|
||||
|
||||
- Windows Terminal spawn with real `wt.exe`.
|
||||
- Real Claude CLI end-to-end session (requires `ANTHROPIC_API_KEY`).
|
||||
- Avalonia hierarchy rendering (chevron, indentation, draft styling, badges).
|
||||
- Session-ID capture from `~/.claude/projects/...` (timing-sensitive, platform-specific).
|
||||
|
||||
---
|
||||
|
||||
## 9. Phasing
|
||||
|
||||
Work is delivered in **three sequential-then-parallel** plans. Plan A must merge before B and C can merge.
|
||||
|
||||
### 9.1 Plan A — Foundation
|
||||
|
||||
Schema migration, enum additions, repository methods, auto-status hook, delete-Restrict, regression test for queue filter. No UI-visible changes (other than delete-with-children now failing with a generic error until Plan C handles it).
|
||||
|
||||
Scope files (approximate):
|
||||
- `src/ClaudeDo.Data/Models/TaskEntity.cs`
|
||||
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`
|
||||
- `src/ClaudeDo.Data/Migrations/<new>_AddPlanningSupport.cs`
|
||||
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (+ `ITaskRepository`)
|
||||
- `src/ClaudeDo.Worker/...` auto-status hook call-site updates.
|
||||
- `tests/ClaudeDo.Worker.Tests/...` new test classes.
|
||||
|
||||
### 9.2 Plan B — Worker MCP + SignalR + Launcher (starts after A merges)
|
||||
|
||||
MCP service with HTTP transport, token auth, six tools. New SignalR hub endpoints for Start/Resume/Discard/Finalize/GetPendingDraftCount. Session directory management. `IPlanningTerminalLauncher` implementation for `wt.exe`. Resolves the four unknowns from §5.8.
|
||||
|
||||
Scope files (approximate):
|
||||
- `src/ClaudeDo.Worker/Planning/PlanningMcpService.cs` (new)
|
||||
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (new)
|
||||
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (extend)
|
||||
- `src/ClaudeDo.Worker/Program.cs` (DI + endpoint mapping)
|
||||
- `src/ClaudeDo.App/` or `src/ClaudeDo.Ui/Services/` — `IPlanningTerminalLauncher` + `WindowsTerminalPlanningLauncher`.
|
||||
- `tests/ClaudeDo.Worker.Tests/Planning/...`
|
||||
|
||||
### 9.3 Plan C — UI (parallel to B after A merges)
|
||||
|
||||
Context menu entries, hierarchy rendering, draft styling, unfinished-session dialog, WorkerClient extensions, delete-with-children handling. During parallel development, mocks the WorkerClient against Plan B's interface contract.
|
||||
|
||||
Scope files (approximate):
|
||||
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
|
||||
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
||||
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
|
||||
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (draft/badge styling)
|
||||
- `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` (new)
|
||||
|
||||
### 9.4 Integration points between B and C
|
||||
|
||||
Interface contract locked before parallel work begins:
|
||||
- SignalR method names, parameters, return DTOs (listed in §6.6).
|
||||
- `TaskUpdated` event payload unchanged; carries the task's new parent-id and status so the UI can re-bucket.
|
||||
- Session directory path shape: `~/.todo-app/planning-sessions/<parentTaskId>/`.
|
||||
- `mcp.json` and session-file formats are internal to Plan B; UI never reads them.
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of scope (for now)
|
||||
|
||||
- Nested planning (children of children). Explicitly one level.
|
||||
- Cross-list planning (parent in list A, children in list B).
|
||||
- Multi-user collaboration on the same planning session.
|
||||
- Session timeouts / auto-discard.
|
||||
- Planning-session history / audit UI. Directory is kept on finalize but not surfaced.
|
||||
- Re-planning a finalized parent (7c: no).
|
||||
230
docs/superpowers/specs/2026-04-23-self-update-design.md
Normal file
230
docs/superpowers/specs/2026-04-23-self-update-design.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Self-Update for App and Installer — Design
|
||||
|
||||
**Date:** 2026-04-23
|
||||
**Status:** Approved
|
||||
|
||||
## Goals
|
||||
|
||||
Give ClaudeDo two update paths:
|
||||
|
||||
- **A — App-side update check:** the Avalonia UI checks Gitea for a newer release on startup (and via a manual menu action) and surfaces a dismissible banner. Clicking **Update now** launches the locally installed installer in Update mode and closes the UI.
|
||||
- **B — Installer self-update:** the WPF installer checks for a newer installer binary on launch and offers to replace itself before continuing. After replacement, it proceeds with its normal wizard.
|
||||
|
||||
Non-goals:
|
||||
|
||||
- No silent/background auto-apply of updates. The user always initiates the final update action.
|
||||
- No periodic in-app polling (startup + manual only).
|
||||
- No changes to the release pipeline — release assets (`ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, `checksums.txt`) stay as they are.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
A new shared library, `ClaudeDo.Releases`, hosts the release-API client, version comparison, checksum verification, and installer self-update logic. Both `ClaudeDo.Installer` and `ClaudeDo.Ui` reference it. This removes the existing duplication between the installer's release plumbing and what the app needs, and keeps a single asset-matching / version-parsing code path.
|
||||
|
||||
```
|
||||
ClaudeDo.Releases (new, netstandard2.0 or net8.0)
|
||||
├── ReleaseClient.cs (moved from Installer/Core)
|
||||
├── IReleaseClient.cs (moved)
|
||||
├── ChecksumVerifier.cs (moved)
|
||||
├── VersionComparer.cs (new)
|
||||
└── SelfUpdater.cs (new — installer self-update mechanism)
|
||||
|
||||
ClaudeDo.Installer (WPF, consumes ClaudeDo.Releases)
|
||||
├── App.xaml.cs (modified — SelfUpdater + --replace-self arg)
|
||||
└── Core/InstallModeDetector.cs (modified — now uses VersionComparer)
|
||||
|
||||
ClaudeDo.Ui (Avalonia, consumes ClaudeDo.Releases)
|
||||
├── Services/UpdateCheckService.cs (new)
|
||||
├── Services/InstallerLocator.cs (new)
|
||||
├── ViewModels/MainViewModel.cs (modified — banner + Help menu state)
|
||||
└── Views/MainWindow.axaml (modified — banner + Help dropdown)
|
||||
```
|
||||
|
||||
## Part A — App-Side Update Check
|
||||
|
||||
### `ClaudeDo.Ui/Services/UpdateCheckService.cs`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Read the current app version from `Assembly.GetExecutingAssembly().GetName().Version`.
|
||||
- Call `IReleaseClient.GetLatestReleaseAsync` to fetch the Gitea release.
|
||||
- Use `VersionComparer` to decide whether the latest is newer.
|
||||
- Expose observable properties: `IsUpdateAvailable`, `LatestVersion`, `CurrentVersion`, `IsChecking`, `LastCheckStatus` (`UpToDate | UpdateAvailable | CheckFailed | NeverChecked`).
|
||||
- Expose a `CheckNowAsync` method for the manual Help menu action.
|
||||
|
||||
Lifecycle:
|
||||
|
||||
- Registered as a singleton in DI.
|
||||
- Startup check is fired from `MainViewModel` once the main window is shown. It runs fire-and-forget on a background `Task`; UI never blocks on it.
|
||||
- Manual check is awaited by its command and briefly shows a status message (see UI).
|
||||
|
||||
Error handling:
|
||||
|
||||
- Network / API errors → log to `~/.todo-app/logs/`, set `LastCheckStatus = CheckFailed`, do not surface a banner. Manual check shows a small inline status only.
|
||||
|
||||
### `ClaudeDo.Ui/Services/InstallerLocator.cs`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Resolve the path to the installed `ClaudeDo.Installer.exe` so the UI can launch it.
|
||||
|
||||
Discovery strategy (first hit wins):
|
||||
|
||||
1. Walk up from `AppContext.BaseDirectory` looking for a sibling `install.json`. The installer is at `{installDir}/uninstaller/ClaudeDo.Installer.exe`.
|
||||
2. Fall back to reading `HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo\InstallLocation` (written by the existing `WriteUninstallRegistryStep`).
|
||||
|
||||
If neither yields a valid path, the banner's **Update now** button is disabled with a tooltip explaining the installer could not be located.
|
||||
|
||||
### UI changes
|
||||
|
||||
**Banner** at the top of `MainView` (above content, below custom chrome):
|
||||
|
||||
- Visible when `UpdateCheckService.IsUpdateAvailable == true` and the user has not dismissed this session.
|
||||
- Text: `Update available: v{CurrentVersion} → v{LatestVersion}`.
|
||||
- Actions: `Update now` (primary), `Dismiss` (sets a transient `IsBannerDismissed` flag that resets on app restart — intentionally not persisted so the banner returns next launch if still relevant).
|
||||
- Styled to match existing chrome/accent conventions (compact, dismissible, non-modal).
|
||||
|
||||
**Help dropdown** in the custom titlebar:
|
||||
|
||||
- New menu: `Help`.
|
||||
- First item: `Check for updates` → binds to `MainViewModel.CheckForUpdatesCommand`, which calls `UpdateCheckService.CheckNowAsync`.
|
||||
- When a check completes:
|
||||
- `UpdateAvailable` → banner appears (no separate dialog).
|
||||
- `UpToDate` → a brief inline status in the banner area: `You're up to date (v{CurrentVersion})`, auto-hides after ~3 seconds.
|
||||
- `CheckFailed` → `Could not check for updates` inline message, auto-hides after ~3 seconds.
|
||||
- Leaves room for future items (`About`, `Documentation`, etc.).
|
||||
|
||||
### Update action flow
|
||||
|
||||
1. User clicks **Update now** in the banner.
|
||||
2. `MainViewModel` resolves the installer path via `InstallerLocator`.
|
||||
3. UI spawns `ClaudeDo.Installer.exe` with no arguments. The installer's existing `InstallModeDetector` reads `install.json` alongside it, hits Gitea, and enters `Update` mode.
|
||||
4. UI closes itself immediately after spawning the process.
|
||||
5. The installer performs its standard update flow: `StopServiceStep` → `DownloadAndExtractStep` → `StartServiceStep`.
|
||||
6. When the user clicks Finish the installer exits. The user re-launches the app via their existing shortcut.
|
||||
|
||||
No IPC between UI and installer is needed — the installer is already self-sufficient once launched against an existing install directory.
|
||||
|
||||
## Part B — Installer Self-Update
|
||||
|
||||
### `ClaudeDo.Releases/SelfUpdater.cs`
|
||||
|
||||
Runs from `ClaudeDo.Installer/App.xaml.cs` before any window is shown.
|
||||
|
||||
Flow:
|
||||
|
||||
1. **Handle `--replace-self` argument first.** If the installer was launched with `--replace-self "<old-path>"`:
|
||||
- Wait up to 5 seconds for the old process to exit (poll for file lock release).
|
||||
- Delete `<old-path>`.
|
||||
- Copy own exe to `<old-path>`.
|
||||
- Start a new process at `<old-path>` with no args, then exit the current (temp) process. This ensures the user's shortcut or Apps & Features entry now points at the updated binary.
|
||||
- If any step fails, fall through to the normal wizard (the user still has a working installer, just in a temp location).
|
||||
|
||||
2. **Check for a newer installer.** If no `--replace-self` arg:
|
||||
- Parse own assembly version.
|
||||
- Fetch latest release.
|
||||
- Find the installer asset matching `ClaudeDo.Installer-<version>.exe` (regex: `^ClaudeDo\.Installer-(?<version>[\d\.]+)\.exe$`).
|
||||
- Compare via `VersionComparer`.
|
||||
- If not newer, or if check fails, proceed to the normal wizard (existing Config-mode fallback behavior).
|
||||
|
||||
3. **Prompt if newer.** Show a small modal dialog (plain WPF `Window`, reusing the installer's titlebar/accent styles):
|
||||
> *"A newer installer is available: v{latest}. Update before continuing?"*
|
||||
> `[Update] [Continue anyway] [Cancel]`
|
||||
|
||||
- **Cancel** → `Application.Current.Shutdown(0)`.
|
||||
- **Continue anyway** → proceed to normal wizard.
|
||||
- **Update** → run relaunch sequence.
|
||||
|
||||
4. **Relaunch sequence:**
|
||||
- Download to `%TEMP%\ClaudeDo.Installer-<version>.exe` (show a minimal inline progress UI; no separate window).
|
||||
- Verify against `checksums.txt` from the release via `ChecksumVerifier`. On failure, show error with `[Continue with current installer]` action → proceed to wizard.
|
||||
- `Process.Start` new exe with args `--replace-self "<current-exe-path>"`.
|
||||
- Exit current process.
|
||||
|
||||
### Why `--replace-self` rather than a shell script
|
||||
|
||||
A child process holding a handle to the new exe is reliable cross-Windows-version. Relying on a `.bat` or `cmd /c` helper leaves a file that we would need to clean up, and behaves badly when the installer was launched from a mounted share or non-ASCII path. The `--replace-self` approach keeps everything in managed code and uses a single exe throughout.
|
||||
|
||||
### Edge case: running from `uninstaller/` copy
|
||||
|
||||
When the installer runs from `{installDir}/uninstaller/ClaudeDo.Installer.exe` (via the app's **Update now** or from Apps & Features), the self-update flow is identical. It is desirable for the uninstaller copy to be kept current — stale uninstaller binaries would otherwise drift behind and could have bugs the new app release expects fixed.
|
||||
|
||||
## Version Comparison (`VersionComparer`)
|
||||
|
||||
Centralizes the logic currently in `InstallModeDetector.IsNewer`:
|
||||
|
||||
- Parses both versions as `System.Version` after trimming a leading `v` / `V`.
|
||||
- Returns `(bool isNewer, bool unparseable)`.
|
||||
- Unparseable (e.g. `0.2.0-beta`) → treated as not newer; callers can surface a hint if desired.
|
||||
|
||||
Both `InstallModeDetector` (existing behavior) and `SelfUpdater` / `UpdateCheckService` (new callers) share this logic.
|
||||
|
||||
## Error Handling Summary
|
||||
|
||||
| Scenario | App behavior | Installer behavior |
|
||||
|---|---|---|
|
||||
| Gitea unreachable | Silent; log to file; no banner | Silent; skip self-update; proceed to wizard |
|
||||
| JSON parse error | Same as unreachable | Same as unreachable |
|
||||
| Version unparseable | No banner; log a hint | No prompt; proceed |
|
||||
| Installer exe not found on disk | `Update now` button disabled with tooltip | N/A |
|
||||
| Download fails | N/A (app delegates to installer) | Error dialog with `[Continue with current installer]` |
|
||||
| Checksum mismatch | N/A | Error dialog with `[Continue with current installer]` |
|
||||
| Relaunch fails | N/A | Error dialog; user keeps temp exe and current exe both |
|
||||
|
||||
## Testing
|
||||
|
||||
**New test project: `tests/ClaudeDo.Releases.Tests`**
|
||||
|
||||
- `ReleaseClientTests` — move existing installer tests covering `GetLatestReleaseAsync` and `DownloadAsync`.
|
||||
- `VersionComparerTests` — boundary cases (equal, newer, older, unparseable, mixed `v`-prefix).
|
||||
- `SelfUpdaterTests`:
|
||||
- Asset-name regex correctly isolates version from `ClaudeDo.Installer-0.3.0.exe` and ignores `ClaudeDo-0.3.0-win-x64.zip`.
|
||||
- Decision logic given mocked `IReleaseClient` responses.
|
||||
- `--replace-self` handler: given a temp dummy file, the handler waits, deletes, copies — verified with a mock filesystem / temp dir.
|
||||
|
||||
**Existing project: `tests/ClaudeDo.Installer.Tests`**
|
||||
|
||||
- `SelfUpdateIntegrationTest`: build the installer, invoke it with `--replace-self <dummy>` pointing at a temp file, assert the dummy is replaced by a copy of the test installer, and the process exits cleanly. Run only on Windows CI.
|
||||
|
||||
**App tests (`tests/ClaudeDo.Ui.Tests` — add if absent):**
|
||||
|
||||
- `UpdateCheckServiceTests` — stubbed `IReleaseClient`, assert state transitions for each status.
|
||||
- `InstallerLocatorTests` — fake filesystem, verify walk-up and registry-fallback discovery.
|
||||
|
||||
**Manual verification** (add to `docs/open.md`):
|
||||
|
||||
1. Build `v0.2.x` installer and upload to a test Gitea release.
|
||||
2. Tag `v0.3.0` with new installer asset.
|
||||
3. Install `v0.2.x`, run the `v0.2.x` installer again — confirm self-update prompt appears and replaces the binary in place.
|
||||
4. With `v0.2.x` installed and a v0.3.0 release published, launch the app — confirm banner appears, **Update now** launches installer, update completes, app relaunches at v0.3.0.
|
||||
5. Pull the network during check in both places — confirm silent fallback, no user-visible errors.
|
||||
|
||||
## Files Summary
|
||||
|
||||
**New:**
|
||||
|
||||
- `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`
|
||||
- `src/ClaudeDo.Releases/ReleaseClient.cs` (moved)
|
||||
- `src/ClaudeDo.Releases/IReleaseClient.cs` (moved)
|
||||
- `src/ClaudeDo.Releases/ChecksumVerifier.cs` (moved)
|
||||
- `src/ClaudeDo.Releases/VersionComparer.cs` (new)
|
||||
- `src/ClaudeDo.Releases/SelfUpdater.cs` (new)
|
||||
- `src/ClaudeDo.Ui/Services/UpdateCheckService.cs`
|
||||
- `src/ClaudeDo.Ui/Services/InstallerLocator.cs`
|
||||
- `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`
|
||||
|
||||
**Modified:**
|
||||
|
||||
- `src/ClaudeDo.Installer/App.xaml.cs` — self-update run + `--replace-self` handling before wizard.
|
||||
- `src/ClaudeDo.Installer/Core/InstallModeDetector.cs` — use shared `VersionComparer`; drop now-moved types.
|
||||
- `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — reference `ClaudeDo.Releases`.
|
||||
- `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` — reference `ClaudeDo.Releases`.
|
||||
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` — banner + Help menu dropdown.
|
||||
- `src/ClaudeDo.Ui/ViewModels/MainViewModel.cs` — banner state, `CheckForUpdatesCommand`, wiring to `UpdateCheckService`.
|
||||
- `src/ClaudeDo.App/Program.cs` (or existing DI composition root) — register `UpdateCheckService`, `InstallerLocator`, `IReleaseClient`, `HttpClient`.
|
||||
- `ClaudeDo.slnx` — add new projects.
|
||||
- `docs/open.md` — add manual verification checklist.
|
||||
|
||||
## Open Decisions Deferred to Implementation
|
||||
|
||||
- Exact Avalonia styling/layout of the banner is left to implementation to match the existing chrome polish pass from commit `3c420ac`.
|
||||
- The Help dropdown control type (Avalonia `MenuItem` inside a `Menu`, or a custom flyout) is chosen during implementation based on what fits the current custom titlebar.
|
||||
121
docs/superpowers/specs/2026-04-23-worker-log-footer-design.md
Normal file
121
docs/superpowers/specs/2026-04-23-worker-log-footer-design.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Worker Log Footer — Design
|
||||
|
||||
Date: 2026-04-23
|
||||
|
||||
## Goal
|
||||
|
||||
Surface important Worker lifecycle events (worktree created, Claude started, merged, etc.) in the UI footer as a single rotating, color-coded line. Gives the user ambient awareness of what the Worker just did without opening task details.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No log history, drawer, or scrollback
|
||||
- No filtering or user-configurable verbosity
|
||||
- No persistence across UI restarts
|
||||
- No replay of events missed while UI was disconnected
|
||||
|
||||
## UX
|
||||
|
||||
Footer (`MainWindow.axaml`, row 2) layout changes from `StackPanel` to `DockPanel`:
|
||||
|
||||
- **Docked left:** existing connection pill (ellipse + `ONLINE/OFFLINE/RECONNECTING` text). The static `· WORKER` label is removed; the rotating log line replaces its purpose.
|
||||
- **Docked right:** rotating worker-log line.
|
||||
|
||||
Line format: `14:32 · <message>`, rendered in the mono font at size 10 (matches existing footer typography). `TextTrimming="CharacterEllipsis"` so long task titles don't push out the connection pill.
|
||||
|
||||
The line is hidden when no event has been received within the last 30 seconds. Each new event replaces the current text and resets the 30-second timer. Timestamp is local time, `HH:mm`.
|
||||
|
||||
### Color mapping
|
||||
|
||||
Level is rendered via a `WorkerLogLevelToBrushConverter` (mirrors existing `StatusColorConverter` pattern):
|
||||
|
||||
| Level | Brush / color | Events |
|
||||
|-----------|------------------------|-----------------------------------------------------|
|
||||
| `Info` | `TextDimBrush` (dim) | Created worktree, Started Claude, Committed changes |
|
||||
| `Success` | `#4CAF50` green | Merged, Finished (done) |
|
||||
| `Warn` | `#FFA726` amber | Discarded worktree, Reset |
|
||||
| `Error` | `#EF5350` red | Finished (failed) |
|
||||
|
||||
## Event Catalog
|
||||
|
||||
Seven emit sites. Each is added alongside the existing `_logger.LogInformation(...)` call — no log-sink plumbing, no central event bus.
|
||||
|
||||
| Site | Level | Message |
|
||||
|-------------------------------------------|-----------|-----------------------------------------|
|
||||
| `WorktreeManager.CreateAsync` | `Info` | `Created worktree for "<title>"` |
|
||||
| `WorktreeManager.DiscardAsync` | `Warn` | `Discarded worktree for "<title>"` |
|
||||
| `TaskMergeService.MergeAsync` | `Success` | `Merged "<title>" into <target>` |
|
||||
| `TaskResetService.ResetAsync` | `Warn` | `Reset "<title>"` |
|
||||
| `TaskRunner` — Claude launch | `Info` | `Started Claude for "<title>"` |
|
||||
| `TaskRunner` — auto-commit | `Info` | `Committed changes in "<title>"` |
|
||||
| `TaskRunner` — task finished | `Success` / `Error` | `Finished "<title>" (<status>)` |
|
||||
|
||||
`<title>` is the task's display title; `<target>` is the merge target branch; `<status>` is `done` or `failed`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Shared contract (`ClaudeDo.Data`)
|
||||
|
||||
New enum:
|
||||
|
||||
```csharp
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public enum WorkerLogLevel
|
||||
{
|
||||
Info,
|
||||
Success,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
```
|
||||
|
||||
SignalR is configured to serialize enums as strings via `JsonStringEnumConverter` (added to the hub's JSON options in `Program.cs`). The UI client deserializes back to the same enum.
|
||||
|
||||
### Server side (`ClaudeDo.Worker`)
|
||||
|
||||
`HubBroadcaster` gets a new method:
|
||||
|
||||
```csharp
|
||||
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
|
||||
_hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
|
||||
```
|
||||
|
||||
`HubBroadcaster` is already injected into `TaskRunner`. For `WorktreeManager`, `TaskMergeService`, and `TaskResetService`, add constructor injection where it isn't already present. Each emit site calls `_broadcaster.WorkerLog(...)` with `DateTime.UtcNow` next to the existing `_logger.LogInformation(...)`.
|
||||
|
||||
### Client side (`ClaudeDo.Ui`)
|
||||
|
||||
**`WorkerClient`** — register a `HubConnection.On<string, WorkerLogLevel, DateTime>("WorkerLog", ...)` handler and expose a `WorkerLogReceived` event with a small `WorkerLogEntry(string Message, WorkerLogLevel Level, DateTime TimestampUtc)` record.
|
||||
|
||||
**Footer VM** — `StatusBarViewModel` already exists; extend it (or introduce a small `FooterViewModel` if `StatusBarViewModel` turns out to be scoped elsewhere — confirm during implementation). Add:
|
||||
|
||||
- `[ObservableProperty] string? currentEventText`
|
||||
- `[ObservableProperty] WorkerLogLevel currentEventLevel`
|
||||
- `[ObservableProperty] bool isEventVisible`
|
||||
- A `DispatcherTimer` with a 30-second interval. On each `WorkerLogReceived`:
|
||||
1. Format `HH:mm · <message>` from the event's local time.
|
||||
2. Set `CurrentEventText`, `CurrentEventLevel`, `IsEventVisible = true`.
|
||||
3. Stop and restart the timer.
|
||||
- On timer tick: `IsEventVisible = false`, `CurrentEventText = null`.
|
||||
|
||||
**XAML** — `MainWindow.axaml` footer `StackPanel` becomes a `DockPanel`. Existing ellipses + connection text dock left in a horizontal `StackPanel`. A new `TextBlock` docks right, bound to `CurrentEventText` with `Foreground="{Binding CurrentEventLevel, Converter={StaticResource WorkerLogLevelToBrush}}"`, `IsVisible="{Binding IsEventVisible}"`, and `TextTrimming="CharacterEllipsis"`. Same mono font / size 10 as the rest of the footer.
|
||||
|
||||
**Converter** — `WorkerLogLevelToBrushConverter` in `Converters/` returns a brush per enum value, resolving theme brushes via `Application.Current.Resources` for `Info` (to honor theme swaps) and hard-coding the success/warn/error hex values (those are already hard-coded in the current footer).
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit test `WorkerLogLevelToBrushConverter` with each enum value.
|
||||
- Unit test the footer VM: receiving an event sets text/level/visibility and schedules a clear; a second event within 30s replaces and resets the timer; after 30s of silence the line hides.
|
||||
- Manual smoke: run a task end-to-end and confirm each of the seven events surfaces with the expected color and copy.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **UI disconnected during an event:** event is lost. Acceptable — reconnect resumes receiving new events.
|
||||
- **Burst of events:** each replaces the previous; only the most recent is shown.
|
||||
- **Long task title:** ellipsized by the TextBlock; connection pill on the left stays fully visible.
|
||||
- **Clock skew between Worker and UI:** timestamp is formatted in UI's local time from the wire-format `DateTime` (sent as UTC). Minor skew is cosmetic; no correctness impact.
|
||||
|
||||
## Out of Scope / Future
|
||||
|
||||
- Click-to-expand history drawer
|
||||
- Per-list or per-task event filtering
|
||||
- Persisting the most recent N events across restarts
|
||||
197
docs/superpowers/specs/2026-04-24-planning-merge-all-design.md
Normal file
197
docs/superpowers/specs/2026-04-24-planning-merge-all-design.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Planning Merge-All & Subtask Visibility — Design
|
||||
|
||||
**Date:** 2026-04-24
|
||||
**Status:** Approved design, ready for implementation planning
|
||||
|
||||
## Problem
|
||||
|
||||
Three concrete issues with the current Planning feature:
|
||||
|
||||
1. **Queued subtasks are not visible in the Queue List.** When a planning session finalizes, its subtasks transition to `Queued`, but the Queue List's hierarchy rules only show children when their Planning parent is expanded. A collapsed (or already-`Planned`) parent effectively hides the subtasks.
|
||||
2. **Completed subtasks vanish from view.** Once a subtask becomes `Done`, the regroup logic moves it to the "Completed" bucket. Users expect subtasks to remain visible under their Planning parent until the Planning task itself is marked Done.
|
||||
3. **No aggregated view or bulk merge.** Each subtask must be merged individually through its worktree. There is no way to see a combined diff of all changes produced by a Planning session, and no "merge everything" action.
|
||||
|
||||
## Goals
|
||||
|
||||
- Treat Planning subtasks as belonging to their Planning parent for visibility and lifecycle purposes.
|
||||
- Provide a single aggregated diff view that shows all changes produced by a Planning session.
|
||||
- Provide a single "Merge all" action that sequentially merges all subtasks, with a usable conflict-resolution flow.
|
||||
- Auto-complete the Planning task when all merges succeed.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Building a full-featured in-app diff editor. Textual unified diff is acceptable for now; conflict *editing* happens in VS Code.
|
||||
- Persisting Merge-all progress across worker restarts. Restart clears in-memory orchestration state; user re-starts Merge-all (already-merged subtasks are skipped because their worktrees are `Merged`).
|
||||
- Modifying how individual subtasks are created, executed, or finalized.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Visibility model
|
||||
|
||||
Planning subtasks are exclusively children of their Planning parent until the Planning task transitions to `Done`. The Planning parent acts as a roll-up in the Queue List.
|
||||
|
||||
- Tasks with a non-null `ParentTaskId` are excluded from all virtual lists (`virtual:queued`, `virtual:running`, `CompletedItems`, etc.) as separate rows.
|
||||
- A Planning/Planned task is included in `virtual:queued` if **any** child is `Queued`, and in `virtual:running` if any child is `Running`.
|
||||
- Children are always attached under their parent in the task tree; expansion purely controls visual collapse.
|
||||
- When Merge-all completes successfully, the Planning task is set to `Done` and the entire subtree moves to Completed together.
|
||||
- Status badge on the Planning row summarizes children (e.g., `3/5 queued`, `2 running`, `1 failed`).
|
||||
|
||||
### 2. Planning detail panel
|
||||
|
||||
Extends the existing task detail view. New elements when the selected task is a Planning task:
|
||||
|
||||
- **Subtasks list.** Grouped by status badge (Queued / Running / Done / Failed). Each row preserves existing per-subtask actions (view logs, open worktree, individual merge).
|
||||
- **Merge target dropdown.** Single target branch that applies to all subtasks in Merge-all. Defaults to the branch that was current when the Planning session started.
|
||||
- **`[Review combined diff]` button.** Opens the Aggregated Diff Viewer. Enabled as soon as any subtask has produced a diff.
|
||||
- **`[Merge all subtasks]` button.** Orchestrates sequential merge + auto-Done. Disabled until every subtask is `Done` and every worktree is `Active` or `Merged` (no `Discarded` / `Kept`). Tooltip explains why when disabled (e.g., "2 subtasks still running", "1 subtask failed — resolve first", "1 worktree was discarded").
|
||||
- Existing per-subtask merge action remains available; Merge-all is additive.
|
||||
|
||||
### 3. Aggregated diff viewer
|
||||
|
||||
New Avalonia view `PlanningDiffView` + `PlanningDiffViewModel`, opened as a modal or dedicated tab.
|
||||
|
||||
**Default — grouped by subtask:**
|
||||
- Left pane: subtask list in creation order with `title • +added −deleted • N files`.
|
||||
- Right pane: selected subtask's diff. Reuse any existing diff-rendering control; if none exists, render unified diff text with basic syntax coloring (monospace, minimal decoration).
|
||||
- Summary stats come from `WorktreeEntity.DiffStat`. Raw diff comes from `git diff <base>..<head>` executed in each subtask's worktree via `GitService`. Cached in memory per subtask until the subtask's HEAD moves.
|
||||
|
||||
**Toggle — "Preview combined diff":**
|
||||
- Calls `PlanningAggregator.BuildIntegrationBranchAsync(planningTaskId, targetBranch, ct)`:
|
||||
1. Create/reset branch `planning/<slug>-integration` off the current merge target.
|
||||
2. Merge each subtask's branch sequentially with `--no-ff`.
|
||||
3. On conflict during preview: abort the merge, reset the integration branch, surface a warning identifying which two subtasks conflict. Grouped view remains available.
|
||||
4. On success: compute `git diff <merge-target>..planning/<slug>-integration` and render as a single flat unified diff.
|
||||
- Toggle flips back to grouped mode.
|
||||
|
||||
**Integration-branch lifecycle:** scratch artifact, rebuilt on every preview (deleted + recreated). Cleaned up when the Planning task is marked `Done` or when the Planning session is discarded.
|
||||
|
||||
### 4. Merge-all orchestration
|
||||
|
||||
**Happy path (`PlanningMergeOrchestrator.StartAsync`):**
|
||||
|
||||
1. Pre-flight checks — fail fast with a clear message on any:
|
||||
- Every subtask is `Done`.
|
||||
- Every subtask's worktree is `Active` or `Merged` (no `Discarded` / `Kept`). `Merged` worktrees are allowed so that an interrupted Merge-all can be restarted.
|
||||
- Repo working tree is clean.
|
||||
- No mid-merge in progress in the target repo.
|
||||
2. For each subtask in creation order, skip if its worktree is already `Merged` (idempotent restart). Otherwise call `TaskMergeService.MergeAsync` with `removeWorktree: true` and `leaveConflictsInTree: true`. Each success flips the worktree to `Merged`.
|
||||
3. After the last successful merge:
|
||||
- Set Planning task `Status = Done`.
|
||||
- Call `PlanningAggregator.CleanupIntegrationBranchAsync` if the integration branch exists.
|
||||
- Emit `PlanningCompleted` so the UI removes the row from the Queue List.
|
||||
|
||||
**Conflict path:**
|
||||
|
||||
1. `MergeAsync` with `leaveConflictsInTree: true` reports a conflict, leaves the repo in a mid-merge state, and returns the conflicted file paths (`git diff --name-only --diff-filter=U`).
|
||||
2. Orchestrator halts the loop, stores the in-progress state (remaining subtasks, target branch, current subtask id) in memory, and emits `PlanningMergeConflict(planningTaskId, subtaskId, conflictedFiles)`.
|
||||
3. The UI opens the **Conflict Resolution dialog** — see §5.
|
||||
4. On `ContinueAsync`: calls `TaskMergeService.ContinueMergeAsync(subtaskId)` which stages the recorded files and runs `git commit --no-edit`. Flips worktree to `Merged`. Loop resumes with remaining subtasks.
|
||||
5. On `AbortAsync`: calls `TaskMergeService.AbortMergeAsync(subtaskId)` which runs `git merge --abort`. Planning stays in `Planned`. Already-merged earlier subtasks remain `Merged`. Orchestration state cleared.
|
||||
|
||||
**Idempotent restart:** if the worker restarts mid Merge-all, in-memory state is lost. A fresh `StartAsync` re-runs pre-flight; already-`Merged` worktrees are skipped by the loop (their status gates them out). User experience: "I clicked Merge all again and it continued from where it left off."
|
||||
|
||||
### 5. Conflict Resolution dialog
|
||||
|
||||
Avalonia modal (`ConflictResolutionView` + `ConflictResolutionViewModel`).
|
||||
|
||||
- **Header:** `Conflicts in subtask: <title> merging into <target-branch>`.
|
||||
- **File list:** full absolute paths of conflicted files.
|
||||
- **`[Open all in VS Code]`** — for each file, spawn `code <absolute-path>` via `Process.Start`. If `code` is not on PATH, show an inline error row with the file list so the user can copy paths manually. No popup-on-popup.
|
||||
- **`[I've resolved — continue]`** — calls `ContinuePlanningMerge(planningTaskId)` hub method, closes dialog. The orchestration loop continues with the remaining subtasks.
|
||||
- **`[Abort this merge]`** — calls `AbortPlanningMerge(planningTaskId)` hub method, closes dialog. Planning stays `Planned`.
|
||||
|
||||
### 6. Data model
|
||||
|
||||
**No schema changes.**
|
||||
- Conflicted files are queried from git on demand (`git diff --name-only --diff-filter=U`) while the merge is in progress.
|
||||
- Integration branch name is derived from the Planning task slug: `planning/<slug>-integration`.
|
||||
- Planning completion uses existing `TaskStatus.Done`.
|
||||
|
||||
### 7. Services
|
||||
|
||||
**New:**
|
||||
|
||||
- **`PlanningAggregator`** (`src/ClaudeDo.Worker/Planning/PlanningAggregator.cs`)
|
||||
- `GetAggregatedDiffAsync(planningTaskId, ct)` — returns per-subtask diff entries.
|
||||
- `BuildIntegrationBranchAsync(planningTaskId, targetBranch, ct)` — creates/resets the integration branch, merges subtasks sequentially, returns `(success, combinedDiff)` or `(failure, firstConflictSubtaskId, conflictedFiles)`. Always leaves the integration branch in a consistent state (aborts + resets on failure).
|
||||
- `CleanupIntegrationBranchAsync(planningTaskId, ct)` — deletes the integration branch.
|
||||
|
||||
- **`PlanningMergeOrchestrator`** (singleton, `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs`)
|
||||
- Owns in-memory state per planning task: `{ remainingSubtasks, targetBranch, currentSubtaskId }`.
|
||||
- `StartAsync(planningTaskId, targetBranch)`, `ContinueAsync(planningTaskId)`, `AbortAsync(planningTaskId)`.
|
||||
- Emits SignalR events: `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`, `PlanningMergeAborted`, `PlanningCompleted`.
|
||||
|
||||
**Modified:**
|
||||
|
||||
- **`TaskMergeService`**
|
||||
- `MergeAsync` gets a `leaveConflictsInTree: bool` parameter (default `false`). When `true`, on conflict records conflicted files on the returned result, does **not** call `git merge --abort`.
|
||||
- New `ContinueMergeAsync(taskId, ct)` — stages the recorded conflicted files and runs `git commit --no-edit`, flips worktree to `Merged`.
|
||||
- New `AbortMergeAsync(taskId, ct)` — runs `git merge --abort`, restores pre-merge state.
|
||||
- Existing callers unaffected by the default.
|
||||
|
||||
- **`WorkerHub`** — new methods:
|
||||
- `GetPlanningAggregate(planningTaskId)`
|
||||
- `BuildPlanningIntegrationBranch(planningTaskId, targetBranch)`
|
||||
- `MergeAllPlanning(planningTaskId, targetBranch)`
|
||||
- `ContinuePlanningMerge(planningTaskId)`
|
||||
- `AbortPlanningMerge(planningTaskId)`
|
||||
|
||||
- **`TasksIslandViewModel.Regroup`**
|
||||
- Exclude tasks with `ParentTaskId != null` from virtual lists.
|
||||
- Include Planning parents in `virtual:queued` / `virtual:running` based on children's statuses.
|
||||
- Keep children attached to parent in the tree at all times until Planning is `Done`.
|
||||
|
||||
### 8. UI components (new)
|
||||
|
||||
- `PlanningDiffView` + `PlanningDiffViewModel` — aggregated diff viewer (§3).
|
||||
- `ConflictResolutionView` + `ConflictResolutionViewModel` — conflict dialog (§5).
|
||||
- Planning Detail section inside the existing task detail pane — subtask list + merge target dropdown + two buttons (§2).
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Pre-flight failures** — surface as inline errors in the Planning detail panel. No merge work attempted.
|
||||
- **Preview-build conflict** — keep grouped diff available; show a warning banner identifying the conflicting pair of subtasks.
|
||||
- **Merge-all conflict** — Conflict Resolution dialog (§5). The failed subtask's worktree stays `Active`; prior successes stay `Merged`.
|
||||
- **VS Code not on PATH** — inline error row in the Conflict dialog with copyable file paths.
|
||||
- **Worker restart mid-merge** — in-memory state lost; restarting Merge-all is idempotent because merged worktrees are skipped by status gating.
|
||||
|
||||
## Testing
|
||||
|
||||
Convention: xUnit integration tests with real SQLite and real git (`tests/ClaudeDo.Worker.Tests`).
|
||||
|
||||
**`PlanningAggregatorTests`** — real git fixture
|
||||
- `GetAggregatedDiffAsync` returns one entry per subtask with correct stats.
|
||||
- `BuildIntegrationBranchAsync` with non-conflicting subtasks — success, branch contains all changes.
|
||||
- `BuildIntegrationBranchAsync` with conflicting subtasks — failure, branch reset (not mid-merge), correct subtask id and file list reported.
|
||||
- Rebuild overwrites a stale integration branch.
|
||||
- `CleanupIntegrationBranchAsync` removes the branch.
|
||||
|
||||
**`PlanningMergeOrchestratorTests`** — real git + real DB
|
||||
- Happy path: all subtasks merge → worktrees `Merged`, Planning `Done`, `PlanningCompleted` emitted.
|
||||
- Conflict path: first subtask conflicts → repo left in conflict state, `PlanningMergeConflict` emitted with correct file list, worktree stays `Active`, Planning stays `Planned`.
|
||||
- `ContinueAsync` after conflict: resolution commits, loop proceeds, final state `Done`.
|
||||
- `AbortAsync` after conflict: `merge --abort` restores clean state, earlier merged subtasks remain `Merged`, Planning stays `Planned`.
|
||||
- Pre-flight rejection: running subtask, failed subtask, dirty repo — each returns the expected error with no side effects.
|
||||
- Idempotent restart: partial merge + fresh `StartAsync` — already-`Merged` worktrees skipped.
|
||||
|
||||
**`TaskMergeServiceConflictTests`** (extending existing tests)
|
||||
- `MergeAsync(leaveConflictsInTree: true)` on conflict: no `merge --abort`, returns conflicted files, worktree state unchanged.
|
||||
- `ContinueMergeAsync`: completes in-progress merge, flips worktree to `Merged`.
|
||||
- `AbortMergeAsync`: runs `merge --abort`, restores clean state.
|
||||
|
||||
**`TasksIslandRegroupTests`** — ViewModel unit tests, no DB
|
||||
- Queued subtask with a Planning parent is NOT in `virtual:queued` as its own row.
|
||||
- Planning parent with any Queued child IS in `virtual:queued`.
|
||||
- Done subtask stays nested under Planning parent until Planning is `Done`.
|
||||
- After Planning is marked `Done`, parent + children move to Completed together.
|
||||
|
||||
**Manual smoke test** (documented in PR description):
|
||||
- End-to-end planning session in the app: create plan, finalize, let subtasks run.
|
||||
- Open aggregated diff, toggle Preview combined.
|
||||
- Merge-all happy path.
|
||||
- Merge-all conflict path with VS Code dialog open/continue.
|
||||
- Merge-all conflict path abort.
|
||||
|
||||
## Open questions
|
||||
|
||||
None at this stage. All decisions from the brainstorming session are captured above.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Planning UX Polish + Sequential Subtask Queue
|
||||
|
||||
**Status:** design
|
||||
**Date:** 2026-04-24
|
||||
**Scope:** three small UX changes + one feature — sequential execution of planning subtasks triggered from the context menu.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Collapse the children of a finished planning-parent row in the task list by default.
|
||||
2. Allow the user to collapse the Description section in the Details pane.
|
||||
3. Halve the width of the GridSplitters between islands.
|
||||
4. Let the user queue all subtasks of a planning parent so they run one after another, with a new `Waiting` status for pending siblings.
|
||||
|
||||
## 1. Auto-collapse done planning parents
|
||||
|
||||
**Rule for "done":** a planning parent is "done" when every one of its children has `Status == Done`.
|
||||
|
||||
**Changes:**
|
||||
- `TaskRowViewModel`: add UI-only `[ObservableProperty] bool _areChildrenExpanded`. Default computed from status — `false` when the row is a done planning parent, else `true`. Not persisted.
|
||||
- Add `[RelayCommand] void ToggleChildrenExpanded()`.
|
||||
- `TasksIslandView.axaml` (or `TaskRowView.axaml`): chevron button on the planning-parent row, visible only when `IsPlanningParent && HasPlanningChildren`. Bound to the toggle command.
|
||||
- `TasksIslandViewModel.Regroup()`: before adding child rows to `OpenItems`/`CompletedItems`, check each child's parent row in `Items`. If the parent's `AreChildrenExpanded == false`, skip the child.
|
||||
- When a planning parent flips from "not done" → "done" in `OnWorkerTaskUpdated`, call `Regroup()` so the collapse takes effect.
|
||||
|
||||
No DB changes.
|
||||
|
||||
## 2. Collapsible description in Details pane
|
||||
|
||||
**Changes:**
|
||||
- `DetailsIslandViewModel`: `[ObservableProperty] bool _isDescriptionExpanded = true` + `[RelayCommand] void ToggleDescriptionExpanded()`.
|
||||
- `DetailsIslandView.axaml`: wrap the existing description `TextBox` in a `StackPanel`; add a thin header row with the label "Description" and a chevron button. Body's `IsVisible` binds to the flag.
|
||||
- State is per ViewModel instance — reset to `true` whenever a different task is loaded.
|
||||
|
||||
No persistence.
|
||||
|
||||
## 3. Narrower GridSplitters
|
||||
|
||||
`MainWindow.axaml` lines 158 and 170: `Width="5"` → `Width="3"` on both `GridSplitter` elements.
|
||||
|
||||
That's the whole change.
|
||||
|
||||
## 4. Sequential subtask queue
|
||||
|
||||
### Data
|
||||
|
||||
- `ClaudeDo.Data/Models/TaskStatus.cs`: add a new enum value `Waiting` (lowercase serialized form `waiting`, matching existing convention).
|
||||
- Verify status is stored as string (it should be based on existing patterns). If stored as int, ensure new value gets a stable numeric slot at the end of the enum to avoid breaking existing rows. **No EF migration** beyond what the enum emits automatically.
|
||||
|
||||
### Worker
|
||||
|
||||
- New SignalR hub method: `QueuePlanningSubtasksAsync(string parentTaskId) : Task`.
|
||||
- Loads all children of the parent, ordered by `SortOrder`.
|
||||
- Validates: parent must be a planning parent, children must currently all be in `Manual` or `Planned` (reject if any child is already Queued/Running/Done/Failed, surface a friendly error).
|
||||
- First child → `Queued`. All other children → `Waiting`. Save.
|
||||
- Emit `TaskUpdated` for each affected task.
|
||||
- Chain progression — hook into the existing finish/complete path that already fires `TaskFinished`:
|
||||
- On a child task finishing with status `Done` **and** its parent has waiting siblings: find the next sibling by `(ParentTaskId == parent.Id && Status == Waiting)` ordered by `SortOrder`, flip to `Queued`, emit `TaskUpdated`, and let the existing queue pickup loop pick it up.
|
||||
- On `Failed`: do nothing. Remaining `Waiting` siblings stay waiting. (A toast for failed tasks will be added in a later spec.)
|
||||
|
||||
This logic lives in a new `PlanningChainCoordinator` service (or similar) in `ClaudeDo.Worker/Planning/`, registered as a singleton and wired into whatever already emits task-finished events.
|
||||
|
||||
### UI
|
||||
|
||||
- `TaskRowView` — add context menu entry **"Queue subtasks sequentially"**:
|
||||
- `IsVisible` bound to `IsPlanningParent && HasPlanningChildren`.
|
||||
- `IsEnabled` when all children are in `Manual` / `Planned` state (new property on `TaskRowViewModel`: `CanQueueSubtasksSequentially`).
|
||||
- Calls `WorkerClient.QueuePlanningSubtasksAsync(Id)`.
|
||||
- `TaskRowViewModel`:
|
||||
- Add `IsWaiting => Status == TaskStatus.Waiting` and extend `StatusChipClass` switch to return a new class `"waiting"`.
|
||||
- Add `CanQueueSubtasksSequentially` (computed; requires access to children).
|
||||
- `StatusColorConverter` — add a muted color for `Waiting` (proposed: the existing `TextMuteBrush` or a faint cyan).
|
||||
- Task list — planning parent continues to appear in virtual:queued because it has a `Queued` child (existing logic). **Extend** the virtual:queued match predicate in `TasksIslandViewModel.TaskMatchesList` so a task matches when `Status == Queued || Status == Waiting`. This ensures all sibling subtasks (the queued one + the waiting ones) render under the parent in that list.
|
||||
|
||||
### Client
|
||||
|
||||
- `IWorkerClient` / `WorkerClient`: add `QueuePlanningSubtasksAsync(string parentTaskId)` that calls the hub method.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Toast notifications on subtask failure (separate follow-up spec).
|
||||
- Retrying a stopped chain from a failed task (user does it manually via existing actions).
|
||||
- Persisting the collapse state of planning parents or the Description across sessions.
|
||||
- Drag-to-reorder of waiting subtasks (execution order = `SortOrder` at the moment the chain starts).
|
||||
|
||||
## Validation plan
|
||||
|
||||
Manual:
|
||||
- Plan a task with 3 subtasks. Context-menu → Queue subtasks sequentially. Confirm first = Queued, others = Waiting. Watch the first run to Done, confirm the second flips Queued → Running automatically.
|
||||
- Force-fail subtask 2 (cancel or make it fail). Confirm subtask 3 stays Waiting; no further dispatch.
|
||||
- Once all three are Done, confirm the planning parent auto-collapses in the list.
|
||||
- Toggle the Description chevron in the Details pane on an arbitrary task.
|
||||
- Eyeball the narrower GridSplitter — still resizable, still hittable.
|
||||
|
||||
Automated (minimal — only where cheap):
|
||||
- Worker-level unit test for `PlanningChainCoordinator`: happy-path chain advance on Done; no advance on Failed; correct ordering by `SortOrder`.
|
||||
172
docs/superpowers/specs/2026-04-24-planning-worktree-design.md
Normal file
172
docs/superpowers/specs/2026-04-24-planning-worktree-design.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Planning Session MCP via Ephemeral Worktree
|
||||
|
||||
**Date:** 2026-04-24
|
||||
**Status:** Design approved, pending implementation plan
|
||||
**Scope:** `ClaudeDo.Worker` — planning session launch, MCP config delivery
|
||||
|
||||
## Problem
|
||||
|
||||
When a user starts a planning session, `claude` is spawned in the list's working directory via Windows Terminal and passed `--mcp-config <absolute-path>` pointing at a session-local `mcp.json`. In practice, the spawned `claude` session does **not** pick up the ClaudeDo MCP server: `mcp__claudedo__*` tools are not available, and no trust prompt is shown. The user has to fall back to the built-in `TaskCreate` tool, which writes nothing to ClaudeDo.
|
||||
|
||||
The `--mcp-config` flag is documented for headless (`-p`) invocations; in interactive TUI mode it appears to be either ignored or silently dropped on at least some CLI versions. The JSON payload itself is already correct (verified against Claude Code docs — `type: "http"` + `Authorization` header is the documented form).
|
||||
|
||||
The reliable path per Claude Code docs is project-root `.mcp.json` auto-discovery plus a one-time trust approval (or `enableAllProjectMcpServers: true`).
|
||||
|
||||
## Goal
|
||||
|
||||
Spawn planning sessions so that `mcp__claudedo__*` tools are available immediately, without modifying any file in the user's working directory and without requiring a trust prompt.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Installer-time MCP registration (rejected — loses per-session token isolation; pollutes every `claude` invocation on the machine).
|
||||
- Changing how task execution (non-planning) spawns `claude`.
|
||||
- Supporting planning on a working directory that is not a git repository.
|
||||
|
||||
## Approach: ephemeral planning worktree
|
||||
|
||||
Each planning session runs inside its own short-lived git worktree, created from `HEAD` of the list's working directory. The worktree is the isolated surface where we write `.mcp.json` and the settings override. The worktree is force-removed on `FinalizeAsync` / `DiscardAsync`.
|
||||
|
||||
### Files changed
|
||||
|
||||
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`
|
||||
- `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs` (extend to carry worktree path + branch name)
|
||||
- `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs` (may drop `McpConfigPath` if no longer used)
|
||||
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs`
|
||||
- `src/ClaudeDo.Worker/Runner/WorktreeMaintenanceService.cs` (optional — defensive startup prune)
|
||||
- DI registration in `src/ClaudeDo.Worker/Program.cs` (inject `GitService`, `WorkerConfig`, `IDbContextFactory<ClaudeDoDbContext>` into `PlanningSessionManager`)
|
||||
|
||||
### Data flow on `StartAsync`
|
||||
|
||||
1. Resolve `list.WorkingDir`; hard-error if `null`, not a directory, or not a git repo (`GitService.IsGitRepoAsync`).
|
||||
2. Resolve `HEAD` via `GitService.RevParseHeadAsync`.
|
||||
3. Resolve worktree strategy from `AppSettingsRepository.GetAsync` (same resolution as `WorktreeManager.CreateAsync`):
|
||||
- `sibling` → `<parent-of-WorkingDir>\.claudedo-worktrees\planning\<taskId>`
|
||||
- `central` → `<CentralWorktreeRoot>\planning\<taskId>`
|
||||
Normalize with `Path.GetFullPath`.
|
||||
4. Branch name: `claudedo/planning/<taskId-stripped-of-dashes>`.
|
||||
5. `GitService.WorktreeAddAsync(list.WorkingDir, branchName, worktreePath, baseCommit, ct)`. On `"already exists"` failure, run the same self-heal pattern as `WorktreeManager.CreateAsync` (list worktrees for branch → force-remove stale → prune → delete branch → retry once).
|
||||
6. Write into the worktree:
|
||||
- `<worktreePath>\.mcp.json` — JSON with env-var expansion for the token (see below).
|
||||
- `<worktreePath>\.claude\settings.local.json` — `{ "enableAllProjectMcpServers": true }` (create `.claude` dir if missing).
|
||||
7. Write session artifacts in the session directory (unchanged from today): `system-prompt.md`, `initial-prompt.txt`. The session-local `mcp.json` is no longer written — drop that write.
|
||||
8. Return `PlanningSessionStartContext` with `WorkingDir = worktreePath` and a new `WorktreePath` field (redundant with `WorkingDir` for now, but explicit for cleanup). Also carry `BranchName` so finalize/discard can delete it.
|
||||
|
||||
### MCP JSON payload
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claudedo": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:47821/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The token never lives on disk in literal form — `${CLAUDEDO_PLANNING_TOKEN}` is expanded by Claude Code at load time from the spawned process's environment.
|
||||
|
||||
### `.claude/settings.local.json` payload
|
||||
|
||||
```json
|
||||
{
|
||||
"enableAllProjectMcpServers": true
|
||||
}
|
||||
```
|
||||
|
||||
Since the worktree is always empty of user customizations (fresh checkout), we write this file unconditionally. No merge / backup logic needed.
|
||||
|
||||
### Launcher changes (`WindowsTerminalPlanningLauncher`)
|
||||
|
||||
- `LaunchStartAsync`:
|
||||
- Set `psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token` (new field on `PlanningSessionStartContext`).
|
||||
- `-d` now points at the worktree path (already handled by `ctx.WorkingDir` change).
|
||||
- **Remove** `--mcp-config` and its path argument.
|
||||
- Keep `--allowedTools mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill` — `enableAllProjectMcpServers` only handles trust, not per-tool pre-approval.
|
||||
- Keep `--append-system-prompt-file` as the "single-value flag buffer" before the positional prompt (the existing arg-order concern is unchanged).
|
||||
- `LaunchResumeAsync`:
|
||||
- Same env-var setup.
|
||||
- Same `-d <worktreePath>`.
|
||||
- **Remove** `--mcp-config` (the worktree's `.mcp.json` is discovered automatically).
|
||||
- Keep `--resume <ClaudeSessionId>`.
|
||||
|
||||
### Finalize / Discard
|
||||
|
||||
`PlanningSessionManager.FinalizeAsync` and `DiscardAsync` gain:
|
||||
|
||||
1. Look up the worktree path + branch name (deterministic from `taskId` → reuse the same resolution code as `StartAsync`).
|
||||
2. `GitService.WorktreeRemoveAsync(list.WorkingDir, worktreePath, force: true, ct)` — `--force` because claude may have created scratch files.
|
||||
3. `GitService.BranchDeleteAsync(list.WorkingDir, branchName, force: true, ct)`.
|
||||
4. Delete the session dir as today.
|
||||
|
||||
All three steps are best-effort in `DiscardAsync` (log warnings, don't throw — the user explicitly asked to discard). `FinalizeAsync` should propagate failures, since a failed cleanup leaves resources we care about.
|
||||
|
||||
### Resume
|
||||
|
||||
Resume already looks up `list.WorkingDir` from the list; the worktree path is deterministic from `taskId`. `ResumeAsync` must:
|
||||
|
||||
1. Verify the worktree directory exists; if not, hard-error ("planning session was discarded or lost — cannot resume").
|
||||
2. Return `PlanningSessionResumeContext` with `WorkingDir = worktreePath` and the token (re-read from session state — see Token persistence below).
|
||||
|
||||
### Token persistence
|
||||
|
||||
The token today is generated in `StartAsync` and embedded in `mcp.json` at creation time — never read again. With env-var expansion, the token must be available on **resume**. Options:
|
||||
|
||||
- **A) Persist token to session dir** (`<sessionDir>\token`) with `FileOptions.WriteAllBytes`, restrict file ACL to current user. Read on resume.
|
||||
- **B) Store token hash in DB, raw token in memory only** — breaks across Worker restarts → no resume possible.
|
||||
|
||||
**Chosen: A.** Token file sits inside the existing session directory (`<PlanningSessionManager._rootDirectory>\<taskId>\token`), restricted to the current user via Windows ACLs (`File.SetAccessControl` with an explicit DACL granting `FullControl` to `WindowsIdentity.GetCurrent()` only). Cleaned up in `DiscardAsync`/`FinalizeAsync` with the rest of the session dir.
|
||||
|
||||
### Defensive startup cleanup
|
||||
|
||||
`WorktreeMaintenanceService` already prunes worktrees tracked in the DB. Planning worktrees are **not** in the DB (they're purely filesystem-backed, keyed by `taskId` via path convention). Add a lightweight pass:
|
||||
|
||||
- Enumerate directories matching `<root>\.claudedo-worktrees\planning\*` (for each strategy / central root we know about).
|
||||
- For each, check whether a corresponding session dir exists under `~/.todo-app/sessions/<taskId>`.
|
||||
- If no session dir: `git worktree remove --force` + `git branch -D claudedo/planning/<taskId-stripped>`.
|
||||
|
||||
This is a small addition; if scoped too large, defer to a follow-up and accept that a crashed Worker leaves orphaned worktrees until manual cleanup.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|------|----------|
|
||||
| `list.WorkingDir` not a git repo | Hard-error on `StartAsync`. Surface message in UI. |
|
||||
| Worktree branch already exists from a prior crashed session | Self-heal: force-remove matching worktrees, prune, delete branch, retry once. (Same pattern as `WorktreeManager.CreateAsync`.) |
|
||||
| User closes Windows Terminal without clicking Finalize/Discard | Session dir + worktree remain. `ResumeAsync` works. Startup cleanup handles abandoned sessions whose session dir the user manually deletes. |
|
||||
| Claude creates/edits files in the planning worktree | Discarded with the worktree. No impact on user's real working dir. |
|
||||
| User deletes the session dir out from under the Worker | `ResumeAsync` hard-errors. Startup cleanup GCs the orphaned worktree. |
|
||||
| Two simultaneous planning sessions on the same task | Already prevented by task status transition (`Planning` is exclusive). No new consideration. |
|
||||
| `HEAD` is on a detached commit | `git worktree add` handles this fine — base commit is explicit. |
|
||||
|
||||
## Testing
|
||||
|
||||
Extend `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (or a new file) with integration tests using the real-SQLite + real-git pattern the project already uses:
|
||||
|
||||
- **Start happy path:** worktree dir exists after `StartAsync`, contains `.mcp.json` with `${CLAUDEDO_PLANNING_TOKEN}` literal, contains `.claude/settings.local.json` with `enableAllProjectMcpServers: true`.
|
||||
- **Finalize cleanup:** worktree dir is gone, branch is gone, session dir is gone.
|
||||
- **Discard cleanup:** same as finalize.
|
||||
- **Self-heal:** pre-create a stale branch `claudedo/planning/<id>`, then `StartAsync` must succeed.
|
||||
- **Non-git working dir:** `StartAsync` throws a specific error type.
|
||||
- **Resume after Worker restart:** seed session dir + token file, recreate `PlanningSessionManager`, `ResumeAsync` returns context pointing at the still-existing worktree.
|
||||
|
||||
Mock `IPlanningTerminalLauncher` (already an interface) so tests don't actually spawn `wt.exe`.
|
||||
|
||||
## Trade-offs and alternatives considered
|
||||
|
||||
1. **Write `.mcp.json` into the user's working dir with backup/restore.** Rejected — clobber risk, file-noise on crash, user's `.gitignore` may not cover it, exposes token alongside source even with env-var expansion (because expansion is on claude's side, the raw `${VAR}` string still lives in the user's repo).
|
||||
2. **User-scope registration via installer** (`claude mcp add --scope user`). Rejected — requires a static secret baked into the Worker, loses per-session isolation, every `claude` session on the machine sees claudedo tools.
|
||||
3. **Keep `--mcp-config` and debug why it's not honored.** Rejected — even if it works on the maintainer's machine, the behavior is undocumented for interactive TUI mode, and we'd need a fallback anyway. Fixing to the documented path eliminates the uncertainty.
|
||||
|
||||
## Open questions resolved
|
||||
|
||||
- **WorkingDir must be a git repo?** Yes — hard-error.
|
||||
- **Worktree path strategy?** Follow the same `sibling`/`central` setting as task execution.
|
||||
- **HEAD snapshot vs WIP?** HEAD snapshot is fine — planning proposes subtasks, doesn't edit files.
|
||||
|
||||
## Implementation sequencing
|
||||
|
||||
A separate implementation plan (via `superpowers:writing-plans`) will break this into test-first steps.
|
||||
@@ -0,0 +1,174 @@
|
||||
# External MCP — CRUD Extensions
|
||||
|
||||
**Date:** 2026-04-25
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Give a normal (non-planning) Claude CLI session full control over the ClaudeDo task inbox via the existing always-on `ExternalMcpService`. Primary use case: when a chat session produces scope-creep work, Claude can spin up a fully-formed task — title, description, tags (including the `agent` tag for auto-execution) — without leaving the session.
|
||||
|
||||
The work is purely additive: the `ExternalMcpService` endpoint is already wired, authenticated by the optional `X-ClaudeDo-Key` header, and exposes `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTaskStatus`, `RunTaskNow`, `CancelTask`. Missing for "full CRUD" are tag handling, content updates, deletion, and tag discovery.
|
||||
|
||||
## Scope
|
||||
|
||||
| Tool | Status | Notes |
|
||||
|---|---|---|
|
||||
| `ListTaskLists` | exists | unchanged |
|
||||
| `ListTasks` | exists | unchanged |
|
||||
| `GetTask` | exists | unchanged |
|
||||
| `AddTask` | extend | add optional `tags` parameter |
|
||||
| `UpdateTaskStatus` | exists | unchanged (Manual ↔ Queued) |
|
||||
| `RunTaskNow` | exists | unchanged |
|
||||
| `CancelTask` | exists | unchanged |
|
||||
| `UpdateTask` | new | patch title/description/commitType/tags |
|
||||
| `DeleteTask` | new | delete a task (cascades) |
|
||||
| `SetTaskTags` | new | replace the full tag set on a task |
|
||||
| `ListTags` | new | enumerate all known tag names |
|
||||
|
||||
Out of scope:
|
||||
- List CRUD (creating/renaming/deleting lists) — out of scope for this iteration; UI remains the source of truth for list management.
|
||||
- ListConfig / agent settings overrides — handled by the UI, not surfaced via MCP here.
|
||||
- Tag CRUD beyond auto-creation during `AddTask` / `UpdateTask` / `SetTaskTags`. There is no `DeleteTag` tool; tag rows live as long as some task references them.
|
||||
|
||||
## Authentication
|
||||
|
||||
No change. The endpoint continues to be gated by `ExternalMcpAuthMiddleware` — if `WorkerConfig.ExternalMcpApiKey` is set, callers must include `X-ClaudeDo-Key`; otherwise the loopback-only worker is open to local processes.
|
||||
|
||||
## Tool specifications
|
||||
|
||||
### `AddTask` (extended)
|
||||
|
||||
```
|
||||
AddTask(
|
||||
listId: string,
|
||||
title: string,
|
||||
description: string?,
|
||||
createdBy: string,
|
||||
queueImmediately: bool,
|
||||
tags: string[]?,
|
||||
cancellationToken)
|
||||
-> TaskDto
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Existing behavior preserved. New `tags` parameter, when non-null, attaches the named tags to the new task.
|
||||
- Tag names are matched case-insensitively against existing rows; missing tag rows are auto-created (mirrors `TaskRepository.CreateChildAsync`).
|
||||
- Empty/whitespace tag names are skipped; duplicates are deduplicated.
|
||||
- `tags` is the LAST parameter before `CancellationToken` so existing positional callers are unaffected (CancellationToken is bound by name in MCP runtime; defensive — see Migration).
|
||||
|
||||
### `UpdateTask` (new)
|
||||
|
||||
```
|
||||
UpdateTask(
|
||||
taskId: string,
|
||||
title: string?,
|
||||
description: string?,
|
||||
commitType: string?,
|
||||
tags: string[]?,
|
||||
cancellationToken)
|
||||
-> TaskDto
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Loads the task; throws `InvalidOperationException` if not found.
|
||||
- **Refuses if status is `Running`** — protects in-flight worktrees and the streaming log.
|
||||
- Does NOT change status (use `UpdateTaskStatus`) and does NOT change `createdBy`, `listId`, or `parentTaskId` (audit + structural fields, immutable here).
|
||||
- For each non-null parameter, applies the update. Null means "leave unchanged".
|
||||
- `tags` semantics: full replacement of the tag set (same as `SetTaskTags`). Auto-creates missing tag rows.
|
||||
- Broadcasts `TaskUpdated` on the SignalR hub on success.
|
||||
|
||||
### `DeleteTask` (new)
|
||||
|
||||
```
|
||||
DeleteTask(taskId: string, cancellationToken) -> void
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Loads the task; throws if not found.
|
||||
- **Refuses if status is `Running`** — caller must `CancelTask` first.
|
||||
- Calls `TaskRepository.DeleteAsync` (FK cascades remove `task_tags`, `worktrees`, `task_runs`, `subtasks`).
|
||||
- Broadcasts `TaskUpdated(taskId)` so UIs drop the row.
|
||||
|
||||
### `SetTaskTags` (new)
|
||||
|
||||
```
|
||||
SetTaskTags(taskId: string, tags: string[], cancellationToken) -> TaskDto
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Convenience wrapper for "I just want to (re)set tags". Equivalent to `UpdateTask(taskId, null, null, null, tags)`.
|
||||
- Same validation: refuses if `Running`.
|
||||
- Returns the updated `TaskDto` (with status; tags are not included in `TaskDto` today — see Open Decisions).
|
||||
|
||||
### `ListTags` (new)
|
||||
|
||||
```
|
||||
ListTags(cancellationToken) -> { Id: long, Name: string }[]
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Returns every row in the `tags` table. No filter, no pagination — the table is small (seed values + user-defined).
|
||||
- Lets Claude discover existing tag names (`agent`, `manual`, plus any user-defined) before tagging, avoiding duplicates that differ only by case/whitespace.
|
||||
|
||||
## Repository changes
|
||||
|
||||
`src/ClaudeDo.Data/Repositories/TaskRepository.cs`:
|
||||
|
||||
- Add `public Task SetTagsAsync(string taskId, IReadOnlyList<string> tagNames, CancellationToken ct = default)` — replaces the tag set, auto-creates missing rows. Implementation pattern matches the tag block already inside `CreateChildAsync` and the new `UpdateChildAsync` from the planning-MCP work; consider extracting a private helper `ApplyTagsAsync(TaskEntity, IReadOnlyList<string>, CancellationToken)` shared by both.
|
||||
|
||||
`src/ClaudeDo.Data/Repositories/TagRepository.cs`:
|
||||
|
||||
- Add `public Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)` if it does not already exist. (Matches `ListRepository.GetAllAsync` style.)
|
||||
|
||||
No new tables, no migrations.
|
||||
|
||||
## Service changes
|
||||
|
||||
`src/ClaudeDo.Worker/External/ExternalMcpService.cs`:
|
||||
|
||||
- Add `TagRepository` to the constructor (DI registration is already in place since the planning service uses it).
|
||||
- Extend `AddTask` signature with `IReadOnlyList<string>? tags` and apply via the repository.
|
||||
- Add `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags` methods, each annotated `[McpServerTool, Description("…")]`.
|
||||
- Each new mutating tool calls `_broadcaster.TaskUpdated(taskId)` on success (matches existing pattern in this file).
|
||||
|
||||
DI: `ExternalMcpService` is already registered. If `TagRepository` is not already registered (it is — used by `ListRepository`), no change. If a constructor parameter is added, `Program.cs` does not need changes because services are scoped/transient.
|
||||
|
||||
## Error handling
|
||||
|
||||
All errors raised as `InvalidOperationException` with a human-readable message — matches the existing pattern in `ExternalMcpService` and `PlanningMcpService`. The MCP SDK serializes these to the JSON-RPC error channel; Claude sees the message text directly.
|
||||
|
||||
Specific cases:
|
||||
- Task not found → `"Task {id} not found."`
|
||||
- Running-task guard → `"Cannot {update|delete} a running task. Cancel it first."`
|
||||
- Unknown status (in `UpdateTaskStatus`, unchanged) → `"Unknown status '{x}'."`
|
||||
|
||||
## Testing
|
||||
|
||||
Add `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` (or extend if it exists) with:
|
||||
|
||||
| Test | Asserts |
|
||||
|---|---|
|
||||
| `AddTask_WithTags_AttachesTags` | `tags` param creates and attaches tag rows |
|
||||
| `AddTask_WithUnknownTag_AutoCreatesTagRow` | new tag name produces a row in `tags` table |
|
||||
| `UpdateTask_PatchesNonNullFields` | only non-null fields change |
|
||||
| `UpdateTask_OnRunning_Throws` | `InvalidOperationException` |
|
||||
| `UpdateTask_BroadcastsTaskUpdated` | hub broadcast received |
|
||||
| `UpdateTask_TagsReplaceFullSet` | passing tags=[…] replaces existing tags wholesale |
|
||||
| `DeleteTask_RemovesTaskAndTagJoins` | task and `task_tags` rows gone |
|
||||
| `DeleteTask_OnRunning_Throws` | `InvalidOperationException` |
|
||||
| `SetTaskTags_ReplacesAndBroadcasts` | replacement semantics + broadcast |
|
||||
| `ListTags_ReturnsSeedAndCustomTags` | `agent` + `manual` + any user-defined |
|
||||
|
||||
Existing test infrastructure (`DbFixture`, `FakeHubContext`) is reused. No new fakes required.
|
||||
|
||||
**Caveat:** the test assembly currently fails to compile on `main` because of pre-existing in-progress work on `PlanningChainCoordinator` (missing constructor argument in `WorkerHub`/`TaskRunner` test instantiations). Tests will pass only after that work lands; do not block this design on it.
|
||||
|
||||
## Open decisions (defaults chosen, easy to flip)
|
||||
|
||||
1. **`TaskDto` does not currently include tags.** For consistency, the spec keeps `TaskDto` as-is and ships a separate `ListTags` tool. If preferred, we could add `Tags: string[]` to `TaskDto` so every tool response includes them — small DB cost (one extra `SelectMany`), one struct field added. Default: leave `TaskDto` alone, defer.
|
||||
2. **Per-tag `AddTaskTag` / `RemoveTaskTag` micro-tools.** Skipped — `SetTaskTags` covers the use case, and it's idempotent. Add later if granular ops are wanted.
|
||||
3. **List CRUD via MCP.** Out of scope. UI owns lists.
|
||||
|
||||
## Migration / compatibility
|
||||
|
||||
`AddTask` gains an optional parameter. The MCP server SDK sends parameters by name in JSON-RPC `params`, so existing clients that omit `tags` continue to work without code changes. No version bump required.
|
||||
@@ -0,0 +1,297 @@
|
||||
# Worker State & Queue Consolidation — Design
|
||||
|
||||
**Date:** 2026-04-27
|
||||
**Status:** Approved (brainstorming)
|
||||
**Scope:** `ClaudeDo.Worker` + `ClaudeDo.Data` (TaskEntity, TaskRepository), EF migration
|
||||
|
||||
## Problem
|
||||
|
||||
The worker layer has accumulated structural problems that culminate in a concrete bug — the queue does not pick up tasks created by a planning session.
|
||||
|
||||
### Concrete bug
|
||||
|
||||
`TaskRepository.FinalizePlanningAsync(parentId, queueAgentTasks=true)` only flips a draft child to `Queued` if the child *or* its list carries the `agent` tag:
|
||||
|
||||
```csharp
|
||||
var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag);
|
||||
```
|
||||
|
||||
When neither carries the tag, the child silently becomes `Manual` — the queue ignores it. There is no UI feedback. Users observe "queue never picks up planning tasks".
|
||||
|
||||
### Underlying design issues
|
||||
|
||||
1. **Status enum mixes orthogonal concerns.** Today's `TaskStatus` carries 10 values: lifecycle (`Manual, Queued, Running, Done, Failed`), planning hierarchy (`Planning, Planned`), chain ordering (`Waiting`), and an unclear `Draft`. Every consumer has to know which subset applies in which context.
|
||||
2. **Status writes are scattered.** TaskRunner, StaleTaskRecovery, PlanningChainCoordinator, FinalizePlanningAsync, TaskResetService, ExternalMcpService, and PlanningMcpService all mutate `Status` directly. Some go through `TaskRepository.Mark*Async` helpers, some do `task.Status = …` straight on the DbContext (PlanningChainCoordinator).
|
||||
3. **Guards are duplicated.** `if (Status == Running) throw …` appears in at least four places (delete, retag, merge, reset).
|
||||
4. **Two competing planning flows.** `FinalizePlanningAsync` (parallel queueing in Repo) and `PlanningChainCoordinator.QueueSubtasksSequentiallyAsync` (sequential chain) make incompatible assumptions about child status.
|
||||
5. **`WakeQueue()` is manual.** Multiple callers must remember to invoke it after any DB mutation that creates a `Queued` task. `QueueSubtasksSequentiallyAsync` forgets to. The queue only picks up after a backstop tick.
|
||||
6. **`Worker/Services/` is a grab-bag.** Queue, lifecycle, merge, worktree maintenance, agent files, and recovery sit side-by-side without domain boundaries.
|
||||
|
||||
## Goals
|
||||
|
||||
- One source of truth for status mutations: `TaskStateService`.
|
||||
- Status enum reflects only lifecycle. Planning state and chain blocking are separate fields.
|
||||
- Wake-queue side effects are automatic, not caller-driven.
|
||||
- Planning finalization has exactly one path.
|
||||
- `Worker/Services/` is split into domain folders.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No change to UI status-rendering logic beyond adapting to renamed values.
|
||||
- No change to SignalR/MCP wire formats beyond the necessary status-string updates.
|
||||
- No change to git/worktree behavior.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Status model reform
|
||||
|
||||
Replace today's single `TaskStatus` with three orthogonal fields on `TaskEntity`.
|
||||
|
||||
#### `TaskStatus` (lifecycle only) — 6 values
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `Idle` | not in queue, not active. Replaces today's `Manual` and `Draft`. |
|
||||
| `Queued` | waiting for queue pickup. |
|
||||
| `Running` | currently executing. |
|
||||
| `Done` | finished successfully. |
|
||||
| `Failed` | finished with error. |
|
||||
| `Cancelled` | aborted by user (today conflated with `Failed`). |
|
||||
|
||||
#### `PlanningPhase` (parent-only, new column) — 3 values
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `None` | no planning session. Default for all tasks. |
|
||||
| `Active` | planning session is running. Replaces `Status=Planning`. |
|
||||
| `Finalized` | plan is committed, children exist. Replaces `Status=Planned`. |
|
||||
|
||||
A parent task can now be `Status=Idle, PlanningPhase=Finalized` simultaneously, enabling re-runs of finalized plans without losing planning metadata.
|
||||
|
||||
#### `BlockedByTaskId` (nullable FK, new column) — replaces `Waiting`
|
||||
|
||||
- Today: `Status=Waiting` means "waiting on a predecessor in the chain".
|
||||
- New: `Status=Queued` AND `BlockedByTaskId=<predecessor>`. Picker filters out any row with `BlockedByTaskId IS NOT NULL`.
|
||||
- `ON DELETE SET NULL` — if predecessor is deleted, child becomes pickable.
|
||||
|
||||
### 2. `TaskStateService` (centralized state machine)
|
||||
|
||||
The only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All other code goes through it.
|
||||
|
||||
```csharp
|
||||
public interface ITaskStateService
|
||||
{
|
||||
Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct);
|
||||
Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct);
|
||||
Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
|
||||
Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
|
||||
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
|
||||
Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
|
||||
|
||||
Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct);
|
||||
Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct);
|
||||
|
||||
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
|
||||
Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
|
||||
|
||||
Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record TransitionResult(bool Ok, string? Reason);
|
||||
```
|
||||
|
||||
#### Allowed transitions
|
||||
|
||||
```
|
||||
Idle → Queued | Running (RunNow)
|
||||
Queued → Running | Cancelled | Idle (ResetToIdle)
|
||||
Running → Done | Failed | Cancelled
|
||||
Done → Idle (ResetToIdle, for re-run)
|
||||
Failed → Idle | Queued (re-queue)
|
||||
Cancelled → Idle | Queued
|
||||
```
|
||||
|
||||
Anything else returns `TransitionResult(false, "invalid transition X→Y")`. No exceptions for invalid transitions — Result pattern keeps callers tolerant.
|
||||
|
||||
#### Invariants
|
||||
|
||||
1. **Atomic.** Each transition is a single `ExecuteUpdate` (or short tx) using `WHERE Status = <expected>` to be TOCTOU-free.
|
||||
2. **Validated.** Source status is verified at the SQL level, not in C#.
|
||||
3. **Side effects (after successful DB write):**
|
||||
- On any `→ Queued`: `IQueueWaker.Wake()`.
|
||||
- On any successful transition: `HubBroadcaster.TaskUpdated(taskId)`.
|
||||
- On `Done`/`Failed`/`Cancelled` for a child task: `IPlanningChainCoordinator.OnChildFinishedAsync`, which calls `_state.UnblockAsync(nextChild)` and `TryCompleteParent` if applicable.
|
||||
4. **No caller responsibility for side effects.** A caller only needs to invoke one method.
|
||||
|
||||
#### Caller migration
|
||||
|
||||
| Today | New |
|
||||
|---|---|
|
||||
| `TaskRunner.MarkRunningAsync` | `_state.StartRunningAsync` |
|
||||
| `TaskRunner.HandleSuccess` (Mark + chain + parent) | `_state.CompleteAsync` (handles all) |
|
||||
| `TaskRunner.HandleFailure` | `_state.FailAsync` |
|
||||
| `StaleTaskRecovery.FlipAllRunningToFailedAsync` | `_state.RecoverStaleRunningAsync("worker restart")` |
|
||||
| `PlanningChainCoordinator.QueueSubtasksSequentiallyAsync` (direct DbContext) | iterates children, calls `_state.EnqueueAsync` for first, `_state.BlockOnAsync` for rest |
|
||||
| `TaskRepository.FinalizePlanningAsync` | **removed**; `PlanningSessionManager` orchestrates via state-service |
|
||||
| `TaskResetService` (direct DbContext) | `_state.ResetToIdleAsync` (service only owns worktree-cleanup) |
|
||||
|
||||
`Mark*Async` repo helpers stay but become `internal` — used only by `TaskStateService`.
|
||||
|
||||
### 3. Queue dispatch & wake mechanics
|
||||
|
||||
Three classes, clear responsibilities.
|
||||
|
||||
#### `IQueueWaker`
|
||||
|
||||
```csharp
|
||||
public interface IQueueWaker { void Wake(); }
|
||||
```
|
||||
|
||||
- Singleton. Backed by today's `SemaphoreSlim`.
|
||||
- Called automatically by `TaskStateService` after any `→ Queued` transition.
|
||||
- Manual `WakeQueue()` calls in app code are removed (Hub `WakeQueue` SignalR endpoint stays for diagnostics but maps directly to `IQueueWaker.Wake`).
|
||||
|
||||
#### `IQueuePicker`
|
||||
|
||||
```csharp
|
||||
public interface IQueuePicker
|
||||
{
|
||||
Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
- The single place where queue selection happens.
|
||||
- Filter (all required):
|
||||
- `Status == Queued`
|
||||
- `BlockedByTaskId IS NULL`
|
||||
- `(ScheduledFor IS NULL OR ScheduledFor <= :now)`
|
||||
- `EXISTS task_tags WHERE name='agent'` OR `EXISTS list_tags WHERE name='agent'`
|
||||
- Order: `SortOrder ASC, CreatedAt ASC`.
|
||||
- Atomic claim via `UPDATE … RETURNING` (matching today's pattern), flips `Queued → Running` and writes `StartedAt`.
|
||||
- Picker is the sole caller of `Queued → Running` transition. `TaskStateService.StartRunningAsync` exists for the override slot path (RunNow / Continue).
|
||||
|
||||
#### `QueueService` (BackgroundService) — slimmer
|
||||
|
||||
- Wait on wake-signal or backstop timer.
|
||||
- Call `_picker.ClaimNextAsync`.
|
||||
- If task: occupy queue slot, run via `_runner.RunAsync`, in `ContinueWith` invoke `_waker.Wake()` for the next pickup.
|
||||
- No DbContext. No status mutation. No DTO knowledge.
|
||||
|
||||
#### `OverrideSlotService` (new)
|
||||
|
||||
- Owns `RunNow` and `ContinueTask` (today both in `QueueService`).
|
||||
- Holds the override slot state.
|
||||
- Status mutations go through `TaskStateService.StartRunningAsync` (non-atomic claim — caller-driven, fine because override is user-initiated and serialized by slot lock).
|
||||
|
||||
### 4. Planning chain integration
|
||||
|
||||
Single flow, replaces both `FinalizePlanningAsync` (Repo) and `QueueSubtasksSequentiallyAsync` (Coordinator).
|
||||
|
||||
1. `PlanningSessionManager.StartAsync(parentId)` → `_state.StartPlanningAsync` → parent `PlanningPhase=Active`.
|
||||
2. User edits children in MCP tool. Children are in `Status=Idle`.
|
||||
3. `PlanningSessionManager.FinalizeAsync(parentId)`:
|
||||
- `_state.FinalizePlanningAsync(parentId)` → parent `PlanningPhase=Finalized, Status=Idle`.
|
||||
- `_chainCoordinator.SetupChainAsync(parentId)`:
|
||||
- Attaches `agent` tag to all children (automatic — confirmed in brainstorming).
|
||||
- `_state.EnqueueAsync(children[0])` → wake fires.
|
||||
- `_state.BlockOnAsync(children[i], children[i-1])` for `i ≥ 1`.
|
||||
4. When a child finishes, `TaskRunner.HandleSuccess` calls `_state.CompleteAsync(child)`. State-service internally invokes `_chainCoordinator.OnChildFinishedAsync`, which calls `_state.UnblockAsync(nextChild)` (wake fires). Predecessor block goes away because of `ON DELETE SET NULL`-style logic in `UnblockAsync`.
|
||||
5. When all children are terminal: `_state` runs `TryCompleteParent` and sets parent `Done`/`Failed` based on aggregate.
|
||||
|
||||
`TaskRepository.FinalizePlanningAsync` is **deleted**. `QueueSubtasksSequentiallyAsync` is renamed to `SetupChainAsync` and made internal to the coordinator (called only from `PlanningSessionManager.FinalizeAsync`).
|
||||
|
||||
### 5. `Worker/Services/` reorganization
|
||||
|
||||
```
|
||||
Worker/
|
||||
State/
|
||||
ITaskStateService.cs
|
||||
TaskStateService.cs
|
||||
TransitionResult.cs
|
||||
Queue/
|
||||
IQueueWaker.cs
|
||||
IQueuePicker.cs
|
||||
QueuePicker.cs
|
||||
QueueService.cs (BackgroundService, slimmer)
|
||||
OverrideSlotService.cs
|
||||
QueueSlotState.cs
|
||||
Lifecycle/
|
||||
StaleTaskRecovery.cs
|
||||
TaskResetService.cs
|
||||
TaskMergeService.cs
|
||||
Worktrees/
|
||||
WorktreeMaintenanceService.cs
|
||||
Agents/
|
||||
AgentFileService.cs
|
||||
DefaultAgentSeeder.cs
|
||||
Runner/ (unchanged)
|
||||
Planning/ (ChainCoordinator simplified)
|
||||
External/ (unchanged)
|
||||
Hub/ (unchanged)
|
||||
```
|
||||
|
||||
`WorkerHub` calls fewer services — typically `_state.X` plus a domain service for non-status work (Merge, Worktree-Cleanup).
|
||||
|
||||
### 6. EF migration
|
||||
|
||||
```sql
|
||||
ALTER TABLE tasks ADD COLUMN planning_phase INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE tasks ADD COLUMN blocked_by_task_id TEXT NULL REFERENCES tasks(id) ON DELETE SET NULL;
|
||||
CREATE INDEX ix_tasks_blocked_by ON tasks(blocked_by_task_id);
|
||||
|
||||
UPDATE tasks SET status='idle' WHERE status='manual';
|
||||
UPDATE tasks SET status='idle' WHERE status='draft';
|
||||
UPDATE tasks SET status='idle', planning_phase=1 WHERE status='planning';
|
||||
UPDATE tasks SET status='idle', planning_phase=2 WHERE status='planned';
|
||||
```
|
||||
|
||||
`Waiting` migration uses a CTE with `LAG()` to derive `BlockedByTaskId` from `(parent_task_id, sort_order)`:
|
||||
|
||||
```sql
|
||||
WITH ordered AS (
|
||||
SELECT id,
|
||||
LAG(id) OVER (PARTITION BY parent_task_id ORDER BY sort_order, created_at) AS prev_id
|
||||
FROM tasks WHERE status='waiting'
|
||||
)
|
||||
UPDATE tasks SET status='queued',
|
||||
blocked_by_task_id=(SELECT prev_id FROM ordered WHERE ordered.id=tasks.id)
|
||||
WHERE id IN (SELECT id FROM ordered);
|
||||
```
|
||||
|
||||
Migration runs at worker startup via the existing `MigrateAsync` flow.
|
||||
|
||||
`Down()` is best-effort (local-only app). Reverse mapping is lossy: `Cancelled` → `Failed`, `BlockedByTaskId` → `Waiting`, planning fields → folded back into status.
|
||||
|
||||
### 7. Test strategy
|
||||
|
||||
New test fixtures (xUnit, real SQLite, real git where needed):
|
||||
|
||||
1. **`TaskStateServiceTests`** — happy path + reject for every transition; mock `IQueueWaker`, `HubBroadcaster`, `IPlanningChainCoordinator` and verify side-effect invocations; concurrency test (two parallel `StartRunningAsync` → exactly one wins).
|
||||
2. **`QueuePickerTests`** — filter logic (blocked, missing tag, future schedule, wrong status) and ordering (`sort_order, created_at`); two parallel pickers → exactly one claims a row.
|
||||
3. **`PlanningChainCoordinatorTests`** — `SetupChainAsync` produces correct (`Queued`, `BlockedBy`) layout; `OnChildFinishedAsync` unblocks the next child; child failure leaves remaining blocked, parent transitions to `Failed` after `TryCompleteParent`.
|
||||
4. **`PlanningEndToEndTests`** — regression for the original bug. `Active` parent + 3 drafts → `Finalize` → assert first child reaches `Running` within 200 ms with no manual `Wake`.
|
||||
5. **Existing tests** — anything seeding `task.Status = TaskStatus.Manual` or similar gets updated to new enum values or routed through `_state`.
|
||||
|
||||
Coverage target: state machine + queue picker at ≥90% branch coverage. Existing coverage levels preserved elsewhere.
|
||||
|
||||
### 8. Implementation slices
|
||||
|
||||
Each slice is one PR with green tests before the next starts.
|
||||
|
||||
1. **Slice 1 — Status model + migration.** New enum values, new columns, EF migration. Existing code mapped to new values mechanically (no behavior change).
|
||||
2. **Slice 2 — `TaskStateService`.** Service + interface + tests. Migrate TaskRunner, StaleTaskRecovery, ExternalMcp/PlanningMcp guards, TaskResetService. Mark `Mark*Async` repo helpers `internal`.
|
||||
3. **Slice 3 — `IQueueWaker` + `IQueuePicker`.** Extract from QueueService and Repo. Remove all manual `WakeQueue()` calls in app code.
|
||||
4. **Slice 4 — Planning flow consolidation.** Delete `FinalizePlanningAsync` from repo. `PlanningSessionManager.FinalizeAsync` orchestrates via state-service + ChainCoordinator. Rename `QueueSubtasksSequentiallyAsync` → `SetupChainAsync` (internal). E2E test green.
|
||||
5. **Slice 5 — `OverrideSlotService` + folder reorg.** Extract RunNow / ContinueTask. Move files to new folder structure. Update DI registration.
|
||||
6. **Slice 6 — Cleanup & docs.** Update `Worker/CLAUDE.md`, `docs/plan.md`. Remove dead helpers.
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- **EF migration on existing DBs.** Tested via integration tests that load a pre-migration fixture DB. `MigrateAsync` is already in production use, low risk.
|
||||
- **State-service becomes a god-object.** Mitigated by keeping it narrow: only status/phase/blocked-by writes, no business logic. Worktree, merge, and runner concerns stay in their own services.
|
||||
- **Two paths to `Running` (picker atomic, state-service for override).** Confirmed acceptable in brainstorming. Picker remains the only atomic-claim path; override slot is serialized by slot lock so non-atomic is safe.
|
||||
- **Waiting-migration CTE.** SQLite supports `LAG()` since 3.25. .NET 8's bundled SQLite is well above. Tested in migration unit tests.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None at design time. All knackpunkte resolved during brainstorming.
|
||||
@@ -0,0 +1,272 @@
|
||||
# Tabbed Settings + Prime Claude — Design
|
||||
|
||||
**Date:** 2026-04-28
|
||||
**Status:** Draft for review
|
||||
|
||||
## Goal
|
||||
|
||||
Two related UI changes:
|
||||
|
||||
1. Restructure the existing **Settings modal** from a single scrollable stack into a `TabControl` with focused tabs. Move the read-only "About" content out of Settings entirely, into a new modal accessible from the existing Help menu.
|
||||
2. Add a new **Prime Claude** tab where the user defines date-bounded daily schedules. At each scheduled time, the worker fires a single non-interactive `claude -p "ping" --max-turns 1` call to start Claude's 5-hour usage window early — "priming" the day.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
- Settings tabbed UI with 4 tabs: General, Worktrees, Files, Prime Claude.
|
||||
- New About modal opened from `MainWindow` Help menu.
|
||||
- New `PrimeSchedules` table, repository, EF migration.
|
||||
- New `PrimeScheduler` background service (event-driven, no polling).
|
||||
- New SignalR hub methods + client wiring.
|
||||
- Footer notification on prime fire (success/failure) via `StatusBarView`.
|
||||
- 30-minute catch-up window on app launch / wake.
|
||||
- Tests: scheduler unit tests, tab VM tests.
|
||||
|
||||
### Out of scope
|
||||
- Auto-start ClaudeDo at OS boot.
|
||||
- Multiple pings per day per schedule.
|
||||
- Per-schedule prompt customization (schema reserves the column for future use).
|
||||
- Holiday / calendar integration.
|
||||
- Toast notifications, sound, OS-level notifications.
|
||||
|
||||
## Settings tab layout
|
||||
|
||||
| Tab | Contents (existing sections, no field changes) |
|
||||
|---|---|
|
||||
| **General** | Claude Defaults: instructions, model, max turns, permission mode |
|
||||
| **Worktrees** | Strategy, central root, auto-cleanup, Cleanup button, Force-remove confirm flow |
|
||||
| **Files** | Agents (Restore default agents) + Prompts (System / Planning / Agent open-in-editor rows) |
|
||||
| **Prime Claude** | New — schedule list + add button (see below) |
|
||||
|
||||
- Window stays 580×760, custom title bar preserved.
|
||||
- Footer (Save / Cancel) preserved; Save iterates per-tab VMs.
|
||||
- Status / validation strip stays above the footer.
|
||||
- Tab strip uses the existing section-label style for headers (mono, 10pt, letter-spacing 1.4) so it visually matches the current aesthetic.
|
||||
|
||||
## About modal
|
||||
|
||||
New `AboutModalView` + `AboutModalViewModel`:
|
||||
- Same 4 rows as today's About section: Version, Data folder, Logs folder, Worker config — each with an Open button.
|
||||
- Compact dialog (~480×280), same chrome as `SettingsModalView`.
|
||||
- Wired into `MainWindow` Help menu as a new `<MenuItem Header="About…">` next to "Check for updates".
|
||||
- About content removed from `SettingsModalView` entirely (cleaner: not a setting).
|
||||
|
||||
## Prime Claude tab — UI
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Prime your Claude usage window each morning by firing a single │
|
||||
│ non-interactive `ping` call at a chosen time. Only runs while │
|
||||
│ ClaudeDo is open. If the app starts within 30 min of the target │
|
||||
│ time, the ping fires immediately (catch-up window). │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ ☑ May 5, 2026 → Jun 30, 2026 07:00 Mon–Fri last: today ✕│
|
||||
│ ☐ Jul 1, 2026 → Jul 7, 2026 09:30 All days — ✕│
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ [+ Add schedule] │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Per-row controls:
|
||||
- Enabled checkbox (`Enabled`)
|
||||
- Start date picker (`StartDate`)
|
||||
- End date picker (`EndDate`)
|
||||
- Time-of-day field (`TimeOfDay`, 24h, e.g. `07:00`)
|
||||
- Workdays-only checkbox (`WorkdaysOnly`)
|
||||
- Last run label (`{LastRunAt:g}` or `—` if null)
|
||||
- Delete button (✕, with inline confirm bar matching the Worktrees pattern)
|
||||
|
||||
`+ Add schedule` appends a new row pre-filled with: today, today + 30 days, `07:00`, `WorkdaysOnly = true`, `Enabled = true`.
|
||||
|
||||
Validation per row:
|
||||
- `StartDate <= EndDate`
|
||||
- `TimeOfDay` parses as `HH:mm`
|
||||
- `EndDate >= today` (else mark row disabled-looking + tooltip "expired")
|
||||
|
||||
Persistence: rows save with the rest of the modal on **Save**. On Save, `PrimeClaudeTabViewModel` diffs in-memory rows against the loaded snapshot and emits one hub call per change: `UpsertPrimeSchedule` for new/edited rows, `DeletePrimeSchedule` for removed rows. Cancel discards in-memory edits. No per-row autosave.
|
||||
|
||||
## Data model
|
||||
|
||||
New EF Core entity `PrimeScheduleEntity` in `ClaudeDo.Data/Models/`:
|
||||
|
||||
```csharp
|
||||
public class PrimeScheduleEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
public TimeSpan TimeOfDay { get; set; } // local clock
|
||||
public bool WorkdaysOnly { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
public DateTimeOffset? LastRunAt { get; set; }
|
||||
public string? PromptOverride { get; set; } // reserved, always null today
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
- New `PrimeScheduleConfiguration : IEntityTypeConfiguration<PrimeScheduleEntity>` in `Configuration/`.
|
||||
- New repository `PrimeScheduleRepository` matching the existing async + CancellationToken pattern. Methods: `ListAsync`, `GetAsync(id)`, `UpsertAsync(entity)`, `DeleteAsync(id)`, `UpdateLastRunAsync(id, when)`.
|
||||
- EF migration `AddPrimeSchedules` (auto-named per existing migration history).
|
||||
|
||||
## Worker scheduler — `PrimeScheduler`
|
||||
|
||||
New folder `ClaudeDo.Worker/Prime/`. Class hierarchy:
|
||||
|
||||
- `PrimeScheduler : BackgroundService` — event-driven loop.
|
||||
- `IPrimeRunner` / `PrimeRunner` — fires the actual `claude -p "ping" --max-turns 1` call. Injected so tests can fake it.
|
||||
- `IPrimeClock` / `PrimeClock` — `DateTimeOffset Now { get; }`. Faked in tests.
|
||||
- `PrimeSchedulerOptions` — `CatchUpWindow = TimeSpan.FromMinutes(30)`. Hardcoded today; typed for swappability.
|
||||
|
||||
### Loop
|
||||
|
||||
```text
|
||||
while not cancelled:
|
||||
next = ComputeNextDue(now) # null if no enabled schedules
|
||||
if next is null:
|
||||
await wait-on-signal # blocks until schedules change
|
||||
continue
|
||||
delay = max(0, next.At - now)
|
||||
try:
|
||||
await Task.Delay(delay, linkedToken) # cancellable by signal
|
||||
catch OperationCanceledException:
|
||||
continue # schedules changed → recompute
|
||||
await Fire(next.Schedule)
|
||||
```
|
||||
|
||||
`ComputeNextDue(now)`:
|
||||
- For each enabled schedule:
|
||||
- Determine the next eligible date `d >= today` within `[StartDate, EndDate]`, honoring `WorkdaysOnly`.
|
||||
- Skip the day if `LastRunAt.LocalDate == today` (already fired today).
|
||||
- Build `target = d.At(TimeOfDay)` in local time.
|
||||
- Apply catch-up: if `target < now <= target + 30min` and not already fired today, target = `now` (fire immediately).
|
||||
- If `target < now` (past catch-up window) and `d == today`, advance `d` to next eligible date.
|
||||
- Return the schedule with the smallest `target`.
|
||||
|
||||
### Signal source
|
||||
|
||||
`IPrimeScheduleSignal` — a thin abstraction wrapping a `CancellationTokenSource` reset. The hub calls `Signal()` on:
|
||||
- App start (initial recompute is implicit — service first-run computes immediately).
|
||||
- After `UpsertPrimeSchedule` / `DeletePrimeSchedule`.
|
||||
- After a successful fire (so the next-due is recomputed without polling).
|
||||
|
||||
### Fire
|
||||
|
||||
`PrimeRunner.FireAsync(schedule, ct)`:
|
||||
1. Resolve `claude` executable via existing `ClaudeProcess` discovery.
|
||||
2. Spawn with `cwd = Paths.AppDataRoot()`, args `["-p", "ping", "--max-turns", "1"]`. No worktree, no task entity, no list/tag side effects.
|
||||
3. Capture stdout/stderr; success = exit 0 within a 60s timeout.
|
||||
4. On finish: `await PrimeScheduleRepository.UpdateLastRunAsync(id, now)`, append a one-line summary to `~/.todo-app/logs/prime.log`, broadcast `PrimeFired(success, message, timestamp)` via `HubBroadcaster`.
|
||||
|
||||
Failure modes (network, auth, executable missing) → broadcast a failure message; `LastRunAt` still stamped so the day doesn't keep retrying.
|
||||
|
||||
## SignalR / IPC
|
||||
|
||||
### Hub methods (`WorkerHub`)
|
||||
|
||||
```csharp
|
||||
Task<IReadOnlyList<PrimeScheduleDto>> ListPrimeSchedules();
|
||||
Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto);
|
||||
Task DeletePrimeSchedule(Guid id);
|
||||
```
|
||||
|
||||
DTO mirrors entity minus `CreatedAt` (server-managed).
|
||||
|
||||
### Hub events (broadcast)
|
||||
|
||||
```csharp
|
||||
event PrimeFired(Guid scheduleId, bool success, string message, DateTimeOffset firedAt);
|
||||
```
|
||||
|
||||
The `scheduleId` lets an open Settings modal update the matching row's `LastRunAt` without a full reload. No separate `PrimeSchedulesChanged` event — Settings is the only writer, so the modal's own VM state is authoritative until Save.
|
||||
|
||||
`WorkerClient` adds matching async methods + the event handler.
|
||||
|
||||
## UI wiring
|
||||
|
||||
### ViewModel split
|
||||
|
||||
`SettingsModalViewModel` stops holding field properties directly and becomes a coordinator:
|
||||
|
||||
```csharp
|
||||
public sealed partial class SettingsModalViewModel
|
||||
{
|
||||
public GeneralSettingsTabViewModel General { get; }
|
||||
public WorktreesSettingsTabViewModel Worktrees { get; }
|
||||
public FilesSettingsTabViewModel Files { get; }
|
||||
public PrimeClaudeTabViewModel Prime { get; }
|
||||
|
||||
[RelayCommand] private async Task Save() { ... iterate tabs, call SaveAsync on each ... }
|
||||
}
|
||||
```
|
||||
|
||||
Each tab VM:
|
||||
- Owns its observable properties.
|
||||
- Has `Task LoadAsync()` and `Task SaveAsync()` (or returns a partial DTO the coordinator merges).
|
||||
- Owns its own validation, surfaces `ValidationError`.
|
||||
|
||||
`PrimeClaudeTabViewModel`:
|
||||
- `ObservableCollection<PrimeScheduleRowViewModel> Rows`
|
||||
- `[RelayCommand] AddSchedule()` / `RemoveSchedule(id)`
|
||||
- Subscribes to `WorkerClient.PrimeSchedulesChanged` / `PrimeFired` to keep rows fresh while modal is open.
|
||||
|
||||
### Footer notification
|
||||
|
||||
`StatusBarViewModel`:
|
||||
- New `string? PrimeStatus` property.
|
||||
- Subscribes to `WorkerClient.PrimeFired`.
|
||||
- On event: set `PrimeStatus`, start a `DispatcherTimer` for 5s, clear on tick.
|
||||
- `StatusBarView` gets a `TextBlock` bound to `PrimeStatus`, right-aligned, dim-foreground, only visible when non-empty.
|
||||
|
||||
Format: `"✓ Primed Claude at 07:01"` or `"⚠ Prime failed: <reason>"`.
|
||||
|
||||
### About wiring
|
||||
|
||||
- `MainWindowViewModel` adds `[RelayCommand] OpenAbout()` — opens `AboutModalView` via the existing dialog factory pattern.
|
||||
- `MainWindow.axaml` Help menu gains `<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>`.
|
||||
|
||||
## Tests
|
||||
|
||||
### `ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`
|
||||
|
||||
Real SQLite, fake `IPrimeClock`, fake `IPrimeRunner`. Cases:
|
||||
- Fires once at exact target time.
|
||||
- Fires immediately on startup if within catch-up window.
|
||||
- Skips firing if past catch-up window (waits for next eligible day).
|
||||
- Honors `WorkdaysOnly` (no fire on Sat/Sun).
|
||||
- Honors date range (no fire before StartDate, none after EndDate).
|
||||
- Idempotent: doesn't double-fire if `LastRunAt` is today.
|
||||
- Recomputes on signal (upsert mid-wait).
|
||||
- Disabling a schedule mid-wait recomputes.
|
||||
|
||||
### `ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`
|
||||
|
||||
Cases:
|
||||
- Add row appends with sensible defaults.
|
||||
- Remove row removes from collection.
|
||||
- Validation: StartDate > EndDate flags row as invalid.
|
||||
- Save serializes all rows to repository in one batch.
|
||||
- `PrimeFired` event updates the matching row's `LastRunAt`.
|
||||
|
||||
### `ClaudeDo.Ui.Tests/ViewModels/StatusBarViewModelTests.cs` (extend existing if present, else new)
|
||||
|
||||
- `PrimeFired` sets `PrimeStatus` and clears it after 5s (use a fake `IDispatcherTimer` or an injectable delay).
|
||||
|
||||
## Migration / rollout
|
||||
|
||||
- Single EF migration `AddPrimeSchedules`. Existing DBs upgrade on next launch via the existing migration runner (no manual step).
|
||||
- No data backfill — table starts empty. Users add schedules manually via the new tab.
|
||||
- Backwards compatibility for `AppSettingsEntity`: untouched.
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| App is closed at scheduled time | 30 min catch-up on launch; explicit copy in tab explains the limitation. |
|
||||
| Clock/timezone change while waiting | `Task.Delay` fires on monotonic time; recompute after each fire catches drift on next iteration. Acceptable for a 5h-window primer. |
|
||||
| Claude CLI hangs | 60s timeout on the spawn; failure stamped + broadcast. |
|
||||
| Multiple ClaudeDo instances on same machine | Out of scope (existing app already assumes single instance via fixed SignalR port). |
|
||||
| User edits schedule while scheduler is mid-fire | Fire completes, then signal triggers recompute. No race — `UpdateLastRunAsync` is the last write. |
|
||||
|
||||
## Open questions
|
||||
|
||||
None at design time. Implementation may surface small details (e.g. exact Avalonia controls for date/time pickers — likely `CalendarDatePicker` + a `TextBox` masked to `HH:mm` since Avalonia 12 has no built-in TimePicker on all platforms).
|
||||
@@ -0,0 +1,206 @@
|
||||
# Worktree Overview Modal — Design
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-05-19
|
||||
|
||||
## Problem
|
||||
|
||||
Worktree management is becoming hard to oversee. The current UI only exposes per-task worktree actions (merge / keep / discard) from `TaskDetailView`, plus two global maintenance buttons (`CleanupFinishedWorktrees`, `ResetAllWorktrees`). There is no view that shows *all existing worktrees at a glance* with their state, age, branch, and diff stat. Stale or "phantom" worktrees (DB row but missing directory, or vice versa) have no targeted recovery path.
|
||||
|
||||
## Goals
|
||||
|
||||
- A modal that lists every worktree row from the DB, joined with task + list metadata.
|
||||
- Two entry points: filtered to one list (List context menu), and global grouped by list (Help menu).
|
||||
- Quick per-row actions hidden behind a right-click context menu.
|
||||
- Targeted force-remove for stuck / phantom worktrees.
|
||||
- Manual refresh only; no live SignalR subscription needed.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No auto-refresh / live updates from SignalR events.
|
||||
- No UI tests (the project has none for the Ui project).
|
||||
- No changes to `WorktreeManager`, `TaskRunner`, or the existing per-worktree file-tree modal (`WorktreeModalView`) — it gets reused as the "Show diff" target.
|
||||
|
||||
## UI
|
||||
|
||||
### New view pair
|
||||
|
||||
`WorktreesOverviewModalView` + `WorktreesOverviewModalViewModel`, parallel to existing `WorktreeModalView` (which shows the *file tree inside one* worktree).
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌─ Worktrees [List "Foo"] or Worktrees (all) ───────────────┐
|
||||
│ [ Refresh ] [ Cleanup finished ] │
|
||||
│ │
|
||||
│ ▼ List Foo (global mode only) │
|
||||
│ Title Branch State +/- Age │
|
||||
│ Fix login bug claudedo/ab… Active +42-7 3h ago │
|
||||
│ Add API … claudedo/cd… Merged +8 -0 1d ago │
|
||||
│ ▼ List Bar │
|
||||
│ … │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- `DataGrid` (or `ItemsControl` with Grid template) for rows.
|
||||
- List-filtered mode: no group headers, just the table.
|
||||
- Global mode: `Expander` per list with list name as header (default expanded).
|
||||
- State as a colored badge — new `WorktreeStateColorConverter` analogous to `StatusColorConverter`:
|
||||
- Active=Blue, Merged=Green, Discarded=Gray, Kept=Orange.
|
||||
- Right-click on a row opens a `MenuFlyout` with all actions.
|
||||
- Phantom rows (`PathExistsOnDisk == false`) get a small warning icon in the Path tooltip area.
|
||||
|
||||
### Default sort
|
||||
|
||||
State (Active first), then `CreatedAt` descending. Same inside each list group in global mode.
|
||||
|
||||
### Per-row context menu
|
||||
|
||||
| Item | Enabled when | Behavior |
|
||||
|---|---|---|
|
||||
| Show diff | always | Opens existing `WorktreeModalView` with `WorktreePath` set |
|
||||
| Open in Explorer | `PathExistsOnDisk == true` | `Process.Start("explorer.exe", path)` |
|
||||
| Jump to task | always | Closes modal, selects list + task in main window |
|
||||
| Merge | `State == Active` | Calls existing `MergeTask` hub method |
|
||||
| Discard | `State == Active` | `SetWorktreeState(taskId, Discarded)` |
|
||||
| Keep | `State == Active` | `SetWorktreeState(taskId, Kept)` |
|
||||
| Copy branch | always | Clipboard |
|
||||
| Copy path | always | Clipboard |
|
||||
| —————— | | (separator) |
|
||||
| Force remove | `Task.Status != Running` | Confirmation dialog → `ForceRemoveWorktree(taskId)` (red label) |
|
||||
|
||||
### Bulk buttons (toolbar)
|
||||
|
||||
- **Refresh** — re-runs `GetWorktreesOverview`.
|
||||
- **Cleanup finished** — `CleanupFinishedWorktrees(listId)`; in list-filtered mode acts on that list, in global mode on all.
|
||||
|
||||
### Entry points
|
||||
|
||||
- **List context menu** → "Worktrees anzeigen…" → opens modal in filtered mode (`listId` = the list).
|
||||
- **Help menu** → "Worktrees" → opens modal in global mode (`listId = null`).
|
||||
|
||||
`MainWindowViewModel` gets `OpenWorktreesOverviewCommand(listId)` and `OpenWorktreesOverviewGlobalCommand()`, both using a DI `Func<WorktreesOverviewModalViewModel>` factory analogous to existing editor patterns.
|
||||
|
||||
## SignalR Contract
|
||||
|
||||
### New `WorkerHub` methods
|
||||
|
||||
```csharp
|
||||
Task<IReadOnlyList<WorktreeOverviewDto>> GetWorktreesOverview(string? listId);
|
||||
Task<bool> SetWorktreeState(string taskId, WorktreeState newState);
|
||||
Task<ForceRemoveResultDto> ForceRemoveWorktree(string taskId);
|
||||
```
|
||||
|
||||
`CleanupFinishedWorktrees` already exists — extend its signature to accept an optional `listId`:
|
||||
|
||||
```csharp
|
||||
Task<CleanupResult> CleanupFinishedWorktrees(string? listId); // was: ()
|
||||
```
|
||||
|
||||
`MergeTask` is reused unchanged.
|
||||
|
||||
### DTOs
|
||||
|
||||
```csharp
|
||||
public sealed record WorktreeOverviewDto(
|
||||
string TaskId,
|
||||
string TaskTitle,
|
||||
TaskStatus TaskStatus,
|
||||
string ListId,
|
||||
string ListName,
|
||||
string Path,
|
||||
string BranchName,
|
||||
WorktreeState State,
|
||||
string? DiffStat,
|
||||
DateTime CreatedAt,
|
||||
bool PathExistsOnDisk);
|
||||
|
||||
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||
```
|
||||
|
||||
### Broadcasts
|
||||
|
||||
After successful `SetWorktreeState` and `ForceRemoveWorktree`, fire `HubBroadcaster.WorktreeUpdated(taskId)` so `TaskDetailView` (if open) refreshes. `CleanupFinishedWorktrees` already broadcasts; keep behavior, optionally batch.
|
||||
|
||||
### `WorkerClient` (UI)
|
||||
|
||||
Add wrapper methods for the four new/changed hub calls.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### `WorktreeMaintenanceService`
|
||||
|
||||
```csharp
|
||||
public sealed record ForceRemoveResult(bool Removed, string? Reason);
|
||||
|
||||
public Task<IReadOnlyList<WorktreeOverviewRow>> GetOverviewAsync(string? listId, CancellationToken ct);
|
||||
public Task<CleanupResult> CleanupFinishedAsync(string? listId, CancellationToken ct); // signature extended
|
||||
public Task<ForceRemoveResult> ForceRemoveAsync(string taskId, CancellationToken ct);
|
||||
```
|
||||
|
||||
- `GetOverviewAsync` — joins `worktrees × tasks × lists` (`AsNoTracking`), maps to DTO including `PathExistsOnDisk = Directory.Exists(path)`.
|
||||
- `CleanupFinishedAsync(listId)` — same join as today but also filters `t.ListId == listId` when not null.
|
||||
- `ForceRemoveAsync` — refactors existing `TryRemoveAsync(row, force: true, …)` into a single-row entry point shared with `ResetAllAsync`. Refuses when the task is currently `Running`, returning `ForceRemoveResult(false, "task is currently running")`. Otherwise removes the worktree directory, prunes, deletes the branch, deletes the DB row.
|
||||
|
||||
### `WorktreeRepository`
|
||||
|
||||
`SetStateAsync(string taskId, WorktreeState newState, CancellationToken ct)` already documented in CLAUDE.md. If absent, add it; if present, just expose it via the hub.
|
||||
|
||||
### Unchanged
|
||||
|
||||
`WorktreeManager`, `TaskRunner`, `WorktreeModalView`, all existing merge / cleanup flows.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User opens modal → `WorkerClient.GetWorktreesOverviewAsync(listId)` → bind rows.
|
||||
2. Refresh button → same call.
|
||||
3. Per-row action → corresponding hub call → on success, update the affected row locally (no full reload).
|
||||
4. Bulk Cleanup → hub call → full reload.
|
||||
|
||||
## Force-Remove Semantics
|
||||
|
||||
| Initial state | Result |
|
||||
|---|---|
|
||||
| Active, task not Running | Worktree dir removed, branch deleted, DB row deleted. Task remains in current status (Done/Failed/Idle). |
|
||||
| Active, task Running | Refused with reason "task is currently running". |
|
||||
| Merged / Discarded / Kept | Same removal path. |
|
||||
| Phantom (dir missing) | DB row deleted, branch best-effort deleted. |
|
||||
|
||||
## Testing
|
||||
|
||||
New tests in `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs` (real SQLite, real git):
|
||||
|
||||
1. `GetOverviewAsync_returns_all_when_listId_null`
|
||||
2. `GetOverviewAsync_filters_by_listId`
|
||||
3. `GetOverviewAsync_flags_PathExistsOnDisk_false_for_phantom_row`
|
||||
4. `CleanupFinishedAsync_filters_by_listId`
|
||||
5. `ForceRemoveAsync_removes_active_worktree` (happy path incl. branch delete)
|
||||
6. `ForceRemoveAsync_blocked_when_task_running`
|
||||
7. `ForceRemoveAsync_removes_phantom_row`
|
||||
|
||||
UI verification (manual):
|
||||
|
||||
- Open from list context menu → only that list's rows.
|
||||
- Open from Help menu → all lists grouped, default expanded.
|
||||
- Force-remove an Active worktree → row vanishes, DB row gone, branch deleted.
|
||||
- Force-remove while task Running → toast / dialog with reason, row unchanged.
|
||||
- Cleanup finished in filtered mode → only finished rows of the selected list disappear.
|
||||
- "Show diff" reuses existing `WorktreeModalView`.
|
||||
|
||||
## Files Touched
|
||||
|
||||
**New:**
|
||||
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||||
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
|
||||
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs`
|
||||
- `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs`
|
||||
- `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewDto.cs` (or extend an existing DTOs file)
|
||||
|
||||
**Modified:**
|
||||
- `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs`
|
||||
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||
- `src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs`
|
||||
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` (Help menu entry, list context menu entry)
|
||||
- `src/ClaudeDo.App/Program.cs` (DI registration of new VM)
|
||||
- `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs`
|
||||
@@ -0,0 +1,118 @@
|
||||
# Planning: Draft → Planned → Queue gate
|
||||
|
||||
**Date:** 2026-05-29
|
||||
**Status:** Approved (design)
|
||||
|
||||
## Problem
|
||||
|
||||
When a planning parent is finalized, `PlanningChainCoordinator.SetupChainAsync` immediately
|
||||
enqueues the entire child chain (child[0] runs, successors wait blocked on their predecessor).
|
||||
There is no review step: a user cannot hold finalized subtasks in a "ready but not running"
|
||||
state, and the "DRAFT" label in the UI is only a derived side effect
|
||||
(`TaskRowViewModel.IsDraft => IsChild && Status == Idle`) with no gate behind it — a draft
|
||||
child already satisfies `CanSendToQueue` and can be queued directly.
|
||||
|
||||
We want an explicit lifecycle for planning children:
|
||||
|
||||
- **Draft** — child of a plan still being built (parent `PlanningPhase == Active`). Not queueable.
|
||||
- **Planned** — child of a finalized plan (parent `PlanningPhase == Finalized`), still `Idle`. Queueable.
|
||||
|
||||
Finalizing a plan promotes its children Draft → Planned **without** queuing anything. The user
|
||||
then explicitly sends the plan to the queue, which builds the sequential chain (today's behavior,
|
||||
just user-triggered). The gate is enforced in both the UI and the server so no path (UI, MCP,
|
||||
external agents) can queue or run a Draft child.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Q1 — Finalize semantics:** Finalize auto-marks children **Planned** (not Draft); nothing is
|
||||
queued until the user explicitly sends to queue. Draft exists only while the plan is unfinalized.
|
||||
- **Q2 — Queue granularity:** A single **parent-level** "Send plan to queue" action queues all
|
||||
Planned children as a sequential chain (reuses `SetupChainAsync`). No per-child queueing.
|
||||
- **Q3 — Enforcement:** UI **and** server. The gate is a server invariant in `TaskStateService`,
|
||||
so MCP / external agents are bound by it too.
|
||||
- **Data model — Approach 1 (derive, no schema change):** Draft/Planned is a pure function of the
|
||||
parent's `PlanningPhase`. No new column, no migration, no parent/child drift.
|
||||
|
||||
## Core invariant
|
||||
|
||||
No schema change. A child task's stage is derived from its parent's `PlanningPhase`:
|
||||
|
||||
| Parent `PlanningPhase` | Child (`Status = Idle`) | Queueable? |
|
||||
|---|---|---|
|
||||
| `Active` (plan being built) | **DRAFT** | no |
|
||||
| `Finalized` | **PLANNED** | yes |
|
||||
|
||||
**Server invariant:** a child task (`ParentTaskId != null`) may transition `Idle → Queued` or
|
||||
`Idle → Running` **only if** its parent's `PlanningPhase == Finalized`. Standalone (non-child)
|
||||
tasks are unaffected.
|
||||
|
||||
A failed/cancelled child returning to `Idle` while its parent is still `Finalized` is therefore
|
||||
"Planned" again and re-queueable — desired.
|
||||
|
||||
## Components
|
||||
|
||||
### Worker / server
|
||||
|
||||
1. **`TaskStateService` transition guard** — the single enforcement point. When a child task is
|
||||
about to enter `Queued` or `Running`, look up the parent's `PlanningPhase`; if it is not
|
||||
`Finalized`, return a failed `TransitionResult` (no exception — consistent with the existing
|
||||
no-throw transition pattern). This covers:
|
||||
- UI single-task enqueue (`SetTaskStatus → Queued`)
|
||||
- `RunNow` (`StartRunningAsync`, `Idle → Running`)
|
||||
- the queue picker's `Queued → Running` claim (defense in depth; a Draft child can't reach
|
||||
`Queued` in the first place)
|
||||
- MCP `UpdateTaskStatus(Queued)` / `RunTaskNow`
|
||||
|
||||
2. **Finalize stops auto-queuing** — audit every `FinalizeAsync(taskId, queueAgentTasks, ct)`
|
||||
call site and pass `queueAgentTasks: false`. Known callers to update: the UI finalize command
|
||||
and the planning-MCP finalize tool. After this, `FinalizeAsync` only flips the parent to
|
||||
`Finalized` (children become Planned); `SetupChainAsync` is no longer invoked from finalize.
|
||||
|
||||
3. **New queue action** — add `WorkerHub.QueuePlan(parentTaskId)` →
|
||||
`PlanningChainCoordinator.SetupChainAsync(parentTaskId)`. Guarded so it only runs when the
|
||||
parent is `Finalized`; otherwise returns a failure the UI surfaces. This is the user-triggered
|
||||
replacement for the auto-chain.
|
||||
|
||||
### UI
|
||||
|
||||
4. **`TaskRowViewModel`**
|
||||
- Add `ParentFinalized` (`bool`), set by `TasksIslandViewModel`.
|
||||
- `IsDraft => IsChild && Status == Idle && !ParentFinalized`
|
||||
- `IsPlanned => IsChild && Status == Idle && ParentFinalized`
|
||||
- `CanSendToQueue` gains `&& (!IsChild || ParentFinalized)`
|
||||
- Child badge renders `DRAFT` / `PLANNED` (drive off `IsDraft` / `IsPlanned`).
|
||||
- Raise `PropertyChanged` for the new derived members from the relevant `On*Changed` hooks
|
||||
(`OnStatusChanged`, `OnParentTaskIdChanged`, and a new `OnParentFinalizedChanged`).
|
||||
|
||||
5. **`TasksIslandViewModel`** — when building/refreshing rows, resolve each child's parent
|
||||
`PlanningPhase` from the loaded task set and set `ParentFinalized`. If the parent is not in the
|
||||
loaded set, default to `false` (Draft — the safe, non-queueable default).
|
||||
|
||||
6. **`DetailsIslandViewModel`**
|
||||
- `CanEnqueue` for a selected child additionally requires the parent to be `Finalized`.
|
||||
- Add a parent-level **"Send plan to queue"** command, enabled when the selected task is a
|
||||
`Finalized` planning parent with at least one Planned (`Idle`) child and nothing already
|
||||
queued/running; calls `QueuePlanAsync(parentId)`.
|
||||
|
||||
7. **`IWorkerClient` / `WorkerClient`** — add `QueuePlanAsync(string parentId)`. Update the test
|
||||
fakes (UI + Worker test projects) to implement the new member.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Worker (`TaskStateService`):** child enqueue/run rejected when parent `Active`; allowed when
|
||||
parent `Finalized`. Standalone task enqueue still allowed. Picker skips/ignores draft children.
|
||||
- **Worker (finalize):** `FinalizeAsync(..., queueAgentTasks: false)` flips parent to `Finalized`
|
||||
and queues nothing; children remain `Idle`.
|
||||
- **Worker (`QueuePlan`):** on a `Finalized` parent, builds the sequential chain (child[0]
|
||||
unblocked + queued, successors blocked on predecessor); on a non-`Finalized` parent, fails.
|
||||
- **UI VM (`TaskRowViewModel`):** Draft vs Planned derivation and `CanSendToQueue` gating across
|
||||
parent phases; badge text.
|
||||
- **UI VM (`DetailsIslandViewModel`):** `CanEnqueue` gating for children; "Send plan to queue"
|
||||
enablement.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-child manual promotion while a plan is still being built (Draft → Planned without
|
||||
finalizing). Promotion happens only via finalize.
|
||||
- Per-child independent queueing (Q2 = parent-level chain only).
|
||||
- Any database schema / migration change.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Repo Import List Helper — Design
|
||||
|
||||
**Date:** 2026-05-29
|
||||
**Status:** Approved (pending spec review)
|
||||
|
||||
## Problem
|
||||
|
||||
Creating lists is one-at-a-time: click `+ New list`, then open List Settings to set the
|
||||
working directory. Users with many repos under a few parent folders want to wire them all up
|
||||
in one pass.
|
||||
|
||||
## Goal
|
||||
|
||||
A "list helper" that scans one or more parent folders for git repos, presents them as a
|
||||
checklist, and bulk-creates a list (with `WorkingDir` pre-filled) for each ticked repo.
|
||||
|
||||
## Entry Points
|
||||
|
||||
1. **Help menu** — the title-bar dropdown in `MainWindow.axaml` that contains `About…`,
|
||||
`Worktrees…`, etc. Add a new `MenuItem` `Add repos as lists…` wired to a command on
|
||||
`MainWindowViewModel`.
|
||||
2. **Lists island** — a small folder icon button beside the existing `+ New list` button in
|
||||
`ListsIslandView.axaml`, wired to a command on `ListsIslandViewModel`.
|
||||
|
||||
Both open the same modal.
|
||||
|
||||
## Components
|
||||
|
||||
### `RepoScanner` (new, `ClaudeDo.Ui/Services` or `ClaudeDo.Data`)
|
||||
|
||||
Pure filesystem helper, no git library. Given a parent folder path, enumerates immediate
|
||||
subdirectories and returns those that contain a `.git` entry (directory or file). Kept
|
||||
separate from the VM so it is unit-testable.
|
||||
|
||||
```
|
||||
IReadOnlyList<RepoCandidate> Scan(string parentFolder)
|
||||
record RepoCandidate(string Name, string FullPath)
|
||||
```
|
||||
|
||||
- Skips the parent itself; only immediate children are considered (non-recursive).
|
||||
- `.git` may be a directory (normal repo) or a file (worktree/submodule) — both count.
|
||||
- Returns empty on missing/unreadable folder rather than throwing.
|
||||
|
||||
### `RepoImportModalViewModel` (new, `ClaudeDo.Ui/ViewModels/Modals`)
|
||||
|
||||
Follows the existing modal-VM pattern (`CloseAction`, resolved from DI).
|
||||
|
||||
Dependencies:
|
||||
- `IDbContextFactory<ClaudeDoDbContext>` — load existing lists' `WorkingDir` values (for the
|
||||
"already added" check) and create new `ListEntity` rows. Same dependency
|
||||
`ListsIslandViewModel` already uses.
|
||||
|
||||
State:
|
||||
- `ObservableCollection<RepoImportItemViewModel> Repos` — the combined checklist.
|
||||
- A set of parent folder paths already scanned (to de-dupe re-adds).
|
||||
- `CreateCount` — computed count of ticked-and-new rows (drives the confirm button label).
|
||||
|
||||
Commands:
|
||||
- `AddFolderAsync` — invokes the folder picker (via view code-behind callback, see below),
|
||||
scans each chosen folder with `RepoScanner`, appends new candidates. De-dupes by full path
|
||||
(case-insensitive) against rows already present.
|
||||
- `CreateAsync` — for each ticked, non-existing row, create a `ListEntity` via
|
||||
`ListRepository.AddAsync` (Name = folder name, WorkingDir = full path,
|
||||
DefaultCommitType = `CommitTypeRegistry.DefaultType`, fresh `Guid` id, `CreatedAt` = now).
|
||||
Then `CloseAction()`.
|
||||
- `Cancel` — `CloseAction()`.
|
||||
|
||||
On load, fetch all existing lists once and capture their `WorkingDir`s into a case-insensitive
|
||||
set; each appended candidate whose path is in that set is marked `AlreadyAdded`.
|
||||
|
||||
### `RepoImportItemViewModel` (new)
|
||||
|
||||
- `Name`, `FullPath` (display).
|
||||
- `AlreadyAdded` (bool) — true if a list already points at this path.
|
||||
- `IsChecked` ([ObservableProperty]) — defaults `true` for new repos. For already-added rows it
|
||||
is forced `true` and the checkbox is disabled.
|
||||
- `CanToggle` => `!AlreadyAdded` (binds to checkbox `IsEnabled`).
|
||||
|
||||
### `RepoImportModalView` (new, `ClaudeDo.Ui/Views/Modals`)
|
||||
|
||||
A `Window` styled like the other modals (header bar, body, footer), shown via
|
||||
`ShowDialog(owner)`.
|
||||
|
||||
- **Header:** title `ADD REPOS AS LISTS` + close button.
|
||||
- **Top of body:** `Add folder…` button.
|
||||
- **Body:** scrollable `ItemsControl` over `Repos`. Each row = `CheckBox` (IsChecked two-way,
|
||||
IsEnabled = `CanToggle`) + repo name + dim full path + `(already added)` label when
|
||||
`AlreadyAdded`.
|
||||
- **Footer:** `Create {CreateCount} lists` button (disabled when `CreateCount == 0`) + `Cancel`.
|
||||
- Folder picker lives in the code-behind (mirrors `ListSettingsModalView.BrowseClicked`):
|
||||
`OpenFolderPickerAsync` with `AllowMultiple = true`, results handed to the VM's
|
||||
`AddFolderAsync`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User opens the modal from either entry point → modal loads existing lists' `WorkingDir`s.
|
||||
2. User clicks `Add folder…` → picks one or more parent folders → `RepoScanner` finds repos →
|
||||
rows appended (de-duped), already-added rows shown ticked+disabled.
|
||||
3. User adjusts ticks → clicks `Create N lists`.
|
||||
4. VM creates one `ListEntity` per ticked-new row via `ListRepository`.
|
||||
5. Modal closes → the **caller reloads the Lists island** so new lists appear:
|
||||
- Lists-island entry point: `ListsIslandViewModel.LoadAsync()`.
|
||||
- Help-menu entry point: `MainWindowViewModel` reloads its `Lists` (the
|
||||
`ListsIslandViewModel` instance) after the modal closes.
|
||||
|
||||
## DI / Wiring
|
||||
|
||||
- Register `RepoImportModalViewModel` (transient) alongside other modal VMs.
|
||||
- Register `RepoScanner` if implemented as an injected service; a static helper needs no
|
||||
registration.
|
||||
- `ListsIslandViewModel` gains `Func<RepoImportModalViewModel, Task>? ShowRepoImportModal` and
|
||||
an `OpenRepoImportCommand`, wired in `ListsIslandView.axaml.cs` (mirrors
|
||||
`ShowListSettingsModal`).
|
||||
- `MainWindowViewModel` gains the same `Func` + an `OpenRepoImportCommand`, wired in
|
||||
`MainWindow.axaml.cs`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Unreadable / missing folders: `RepoScanner` returns empty, no crash.
|
||||
- Re-adding a folder already scanned: de-duped by path, no duplicate rows.
|
||||
- Two ticked repos sharing a folder name: both created (list names are not unique) — acceptable.
|
||||
- List creation failure (rare): best-effort per the existing pattern; do not block remaining
|
||||
creations.
|
||||
|
||||
## Testing
|
||||
|
||||
- `RepoScanner` unit tests (the testable seam): a temp directory tree with a mix of git repos
|
||||
(`.git` dir), a `.git`-file repo, plain folders, and an empty/missing parent. Assert only the
|
||||
repo subfolders are returned and missing folders yield empty.
|
||||
- VM-level "already added" logic and `CreateCount` can be exercised if a test seam is convenient,
|
||||
but the filesystem scanner is the primary unit under test. UI wiring verified manually.
|
||||
|
||||
## Out of Scope (YAGNI)
|
||||
|
||||
- Recursive / deep scanning.
|
||||
- Inline editing of the list name before creation.
|
||||
- Setting model / system prompt / agent during import (tuned later per-list in List Settings).
|
||||
- Picking repo folders directly (only parent-folder scan, per decision).
|
||||
@@ -0,0 +1,165 @@
|
||||
# Worker per-user autostart (drop Windows service)
|
||||
|
||||
Status: approved 2026-05-29
|
||||
Author: brainstorm session (mika kuns + Claude)
|
||||
|
||||
## Problem
|
||||
|
||||
The worker runs as a Windows **service** registered under `LocalSystem`. The worker
|
||||
shells out to the `claude` CLI, whose authentication is stored per-user
|
||||
(`%USERPROFILE%\.claude`). Under `LocalSystem` the worker uses the system profile and
|
||||
cannot see the user's Claude login, so task execution fails. The installer even exposes a
|
||||
"Current User" service-account radio that the backend rejects (`RegisterServiceStep`
|
||||
fails the install). Net effect: the only installable configuration cannot authenticate
|
||||
Claude.
|
||||
|
||||
## Goal
|
||||
|
||||
Run the worker as the logged-in **user** so it inherits the user's Claude auth, starting
|
||||
automatically at logon and staying alive in the background (independent of the desktop
|
||||
app, so Prime/scheduled tasks fire when the UI is closed).
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
1. **Lifetime:** background from logon, always — independent of the UI.
|
||||
2. **Mechanism:** per-user **logon Scheduled Task** (`schtasks`), run only when the user is
|
||||
logged on (no stored password), hidden, with restart-on-failure.
|
||||
3. **No console window:** worker becomes `WinExe`; add a **Serilog rolling file sink** so
|
||||
worker diagnostics aren't lost.
|
||||
4. **App ensures running:** "Restart Worker" becomes process-based; on app startup, if
|
||||
SignalR doesn't connect within a few seconds, the app launches the worker.
|
||||
5. **Auto-migrate:** the installer detects and removes the old `ClaudeDoWorker` service,
|
||||
then registers the task. Uninstall removes the task + kills the worker process.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Cross-account elevation (admin elevates as a *different* account than the interactive
|
||||
user). Single-user / user-is-admin is assumed; the task targets the interactive user.
|
||||
- Running the worker when no user is logged on (that's the whole point — it must be a user
|
||||
session for Claude auth).
|
||||
|
||||
---
|
||||
|
||||
## Component changes
|
||||
|
||||
### ClaudeDo.Worker
|
||||
|
||||
- **`ClaudeDo.Worker.csproj`**: `<OutputType>WinExe</OutputType>`. Add packages
|
||||
`Serilog.AspNetCore` and `Serilog.Sinks.File`.
|
||||
- **`Program.cs`**:
|
||||
- Remove `builder.Host.UseWindowsService(...)`.
|
||||
- Configure Serilog file sink: path `<LogRoot>/worker-.log`, `rollingInterval: Day`,
|
||||
`retainedFileCountLimit: 7`, shared write. `LogRoot` comes from `WorkerConfig`
|
||||
(expand `~`). Wire via `builder.Host.UseSerilog(...)`.
|
||||
- **Single-instance guard:** at startup create `new Mutex(true, @"Local\ClaudeDoWorker",
|
||||
out var createdNew)`. If `!createdNew`, log "another worker instance is already
|
||||
running" and exit 0. Hold the mutex for process lifetime. `Local\` namespace = per
|
||||
user session, which is what we want.
|
||||
- CLI preflight (`ClaudeCliPreflight`) behavior unchanged.
|
||||
|
||||
### ClaudeDo.Installer
|
||||
|
||||
- **New `Steps/RegisterAutostartStep.cs`** (`IInstallStep`, "Register Autostart"):
|
||||
- Build a Task Scheduler **definition XML** (UTF-16) and register via
|
||||
`schtasks /Create /TN "ClaudeDoWorker" /XML "<tmpfile>" /F`.
|
||||
- XML shape:
|
||||
- `Principals/Principal`: `UserId` = current interactive user
|
||||
(`WindowsIdentity.GetCurrent().Name`), `LogonType=InteractiveToken`,
|
||||
`RunLevel=LeastPrivilege`.
|
||||
- `Triggers/LogonTrigger` with the same `UserId`.
|
||||
- `Settings`: `Hidden=true`, `MultipleInstancesPolicy=IgnoreNew`,
|
||||
`StartWhenAvailable=true`, `ExecutionTimeLimit=PT0S`,
|
||||
`DisallowStartIfOnBatteries=false`, `StopIfGoingOnBatteries=false`,
|
||||
`RestartOnFailure` with `Interval` (>= `PT1M`; Task Scheduler's minimum granularity
|
||||
is one minute) and `Count=3`.
|
||||
- `Actions/Exec/Command`: quoted path to `<installDir>/worker/ClaudeDo.Worker.exe`.
|
||||
- The XML builder is a **pure function** (string in → XML string out) so it is unit
|
||||
testable without admin.
|
||||
- **`MigrateServiceStep`** (or folded into `RegisterAutostartStep` as a first phase):
|
||||
detect the old service via `sc query ClaudeDoWorker`; if present, `sc stop` then
|
||||
`sc delete` (poll for clearance like the old `RegisterServiceStep` did). No-op when the
|
||||
service doesn't exist (fresh installs).
|
||||
- **Rename `StopServiceStep` → `StopWorkerStep`, `StartServiceStep` → `StartWorkerStep`**,
|
||||
reworked to be process/task based:
|
||||
- Stop: `schtasks /End /TN ClaudeDoWorker` (ignore errors) + kill any
|
||||
`ClaudeDo.Worker` process whose `MainModule.FileName` is under the install dir;
|
||||
wait for exit. This unlocks `worker/` binaries before extract.
|
||||
- Start: `schtasks /Run /TN ClaudeDoWorker` (preferred — launches as the task principal).
|
||||
Used by fresh install (so the worker runs immediately rather than waiting for next
|
||||
logon) and by Settings "restart".
|
||||
- **`Pages/ServicePage/ServicePageViewModel.cs`**: remove `IsLocalSystem`/`IsCurrentUser`
|
||||
radios and `ServiceAccount` usage. Keep SignalR port, Claude CLI path, "Start at logon"
|
||||
toggle (`AutoStart`), restart delay (maps to task `RestartOnFailure/Interval`, clamped
|
||||
to >= 1 min). Update `ServicePageView.xaml` accordingly. Remove `ServiceAccount` from
|
||||
`InstallContext`.
|
||||
- **`RegisterServiceStep.cs`**: deleted (replaced by `RegisterAutostartStep`).
|
||||
- **Pipelines (`InstallPageViewModel`)**:
|
||||
- Fresh: DownloadAndExtract → WriteConfig → InitDatabase → **RegisterAutostart** (incl.
|
||||
migration no-op) → CreateShortcuts → WriteUninstallRegistry → WriteInstallManifest →
|
||||
**StartWorker**.
|
||||
- Update: **StopWorker** → DownloadAndExtract → **RegisterAutostart** (migrates old
|
||||
service) → **StartWorker** → WriteInstallManifest → WriteUninstallRegistry.
|
||||
- **DI (`App.xaml.cs`)**: register the renamed/new steps (concrete + `IInstallStep` where
|
||||
needed, following the existing double-registration pattern).
|
||||
- **`Core/UninstallRunner.cs`**: replace `sc delete ClaudeDoWorker` with
|
||||
`schtasks /Delete /TN ClaudeDoWorker /F` and kill the worker process; also `sc delete`
|
||||
the legacy service best-effort (in case an old service still lingers).
|
||||
|
||||
### ClaudeDo.Ui / ClaudeDo.App
|
||||
|
||||
- **New `Services/WorkerLocator.cs`**: resolve `<installDir>/worker/ClaudeDo.Worker.exe`
|
||||
by walking up for `install.json` then registry `InstallLocation` (mirrors
|
||||
`InstallerLocator`).
|
||||
- **`ViewModels/IslandsShellViewModel.cs`**:
|
||||
- `RestartWorkerService`: drop `System.ServiceProcess.ServiceController`. Kill worker
|
||||
process(es) under the install dir, then `Process.Start(workerExe)`.
|
||||
- **Ensure-running:** on startup, if the `WorkerClient` connection isn't established
|
||||
within ~4s, launch the worker via `WorkerLocator` + `Process.Start`. Guarded so it
|
||||
runs at most once per app session.
|
||||
- Remove the `System.ServiceProcess` package reference / usings if no longer used.
|
||||
|
||||
---
|
||||
|
||||
## Data flow
|
||||
|
||||
- **Logon:** Task Scheduler starts `ClaudeDo.Worker.exe` in the user session → mutex
|
||||
acquired → Serilog file logging → SignalR hub on `127.0.0.1:47821` → app connects.
|
||||
- **App start with worker down:** app waits ~4s for SignalR; if absent, `Process.Start`
|
||||
worker → mutex acquired → hub up → app connects.
|
||||
- **Duplicate launch (task + app race):** second instance fails the mutex → logs → exits 0.
|
||||
- **Restart Worker button:** kill worker proc → relaunch → mutex reacquired.
|
||||
|
||||
## Error handling
|
||||
|
||||
- `schtasks`/`sc` calls go through the existing `ProcessRunner`; non-zero exits surface as
|
||||
`StepResult.Fail` with the captured output (except best-effort cleanup which is ignored).
|
||||
- Worker single-instance: losing the mutex is a normal, non-error exit (code 0).
|
||||
- App ensure-running: `Process.Start` failures are swallowed (the logon task is the primary
|
||||
mechanism; the app launch is a convenience).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit (no admin required):**
|
||||
- Task-definition XML builder: asserts UserId, LogonType, Hidden, RestartOnFailure
|
||||
interval clamping, quoted command path.
|
||||
- `WorkerLocator`: path resolution via temp `install.json`.
|
||||
- Migration decision: given `sc query` output (exists / not-found), decide stop+delete vs
|
||||
no-op — keep the decision pure, mock `ProcessRunner` output.
|
||||
- Restart-delay → task interval clamping (`< 1 min` → `PT1M`).
|
||||
- **Manual verification (post-build, on this machine):**
|
||||
1. Update from installed `1.0.2-alpha`: old service is removed (`sc query ClaudeDoWorker`
|
||||
→ not found), task exists (`schtasks /Query /TN ClaudeDoWorker`), worker process runs
|
||||
as the user, app connects, no console window.
|
||||
2. Worker log file appears at `~/.todo-app/logs/worker-<date>.log`.
|
||||
3. Kill worker → click Restart Worker in app → reconnects.
|
||||
4. Close app, confirm worker still running (Prime/queue alive); reopen app → connects.
|
||||
5. Log off / log on → worker autostarts.
|
||||
6. Uninstall → task gone, worker process gone, (data kept unless opted out).
|
||||
|
||||
## Risks
|
||||
|
||||
- **Task restart granularity is minutes**, not the old seconds-level service restart. The
|
||||
worker's own long-running resilience + the app ensure-running cover short gaps; acceptable.
|
||||
- **Elevated installer must target the interactive user.** Using
|
||||
`WindowsIdentity.GetCurrent().Name` is correct when the user elevates themselves (the
|
||||
assumed single-user case). Documented non-goal otherwise.
|
||||
@@ -0,0 +1,125 @@
|
||||
# External MCP — UI Parity for Start & Observe
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Status:** Approved (design)
|
||||
|
||||
## Goal
|
||||
|
||||
Expand the always-on **External MCP server** (`ExternalMcpService`, exposed on
|
||||
`cfg.ExternalMcpPort` under `/mcp`) so an external Claude session can **start and
|
||||
observe** ClaudeDo work sessions end-to-end, reaching parity with the desktop UI
|
||||
for those two concerns.
|
||||
|
||||
The server's purpose is deliberately scoped: **help the user start sessions and
|
||||
observe them.** It is *not* a git/worktree console — branch merging, worktree
|
||||
resets, and multi-turn continuation are things Claude does *inside* a task, so
|
||||
they stay out of the tool surface.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
**START — set up and launch a session**
|
||||
- *(existing)* `AddTask`, `UpdateTask`, `UpdateTaskStatus` (Idle/Queued), `RunTaskNow`, `CancelTask`, `DeleteTask`
|
||||
- **List management** — create / rename / delete lists; set working dir + default commit type
|
||||
- **List & task config** — per-list defaults and per-task overrides for `model`, `system_prompt`, `agent_path`
|
||||
- **Agents (read-only)** — list agent files and refresh, so Claude can choose a valid `agent_path`
|
||||
- **Reset failed task** — discard the failed worktree and reset the row to Idle (the retry path)
|
||||
|
||||
**OBSERVE**
|
||||
- *(existing)* `ListTaskLists`, `ListTasks`, `GetTask`
|
||||
- **Run history** — read `task_runs` for a task (session id, tokens, turns, result, structured output, error)
|
||||
- **Logs** — fetch a task's (or run's) log output
|
||||
- **App settings (read-only)** — read worker app settings
|
||||
|
||||
### Out of scope (explicitly excluded)
|
||||
- **Tags** — already removed from the system (migration `20260519044715_RemoveTags`); only the stale doc reference in `src/ClaudeDo.Worker/CLAUDE.md` needs deleting.
|
||||
- **Multi-turn continue** (`--resume`) — Claude's own concern inside a task.
|
||||
- **Worktree ops** — merge, merge targets, cleanup-finished, reset-all, force-remove, set-state.
|
||||
- **Start planning session** — not needed via MCP.
|
||||
- **App settings writes** — risky (e.g. flips permission mode); read-only only.
|
||||
- **Agent file create/edit/delete** — not part of "starting a session".
|
||||
|
||||
## Approach (chosen: A)
|
||||
|
||||
**Reuse existing worker services; split the growing tool surface into focused
|
||||
`[McpServerToolType]` classes.** No business logic is duplicated — each new tool
|
||||
injects the same service the SignalR hub already uses, so MCP behavior stays
|
||||
identical to the UI.
|
||||
|
||||
Adding ~12 tools to the single `ExternalMcpService` would push it past 600 lines
|
||||
across eight unrelated jobs. Instead, organize tools by category, mirroring the
|
||||
existing `External/` + `Planning/` layout:
|
||||
|
||||
| Class (new, in `External/`) | Tools | Backing service |
|
||||
|---|---|---|
|
||||
| `ExternalMcpService` *(existing, unchanged scope)* | task CRUD + run/cancel/status | `TaskRepository`, `QueueService`, `ITaskStateService` |
|
||||
| `ListMcpTools` | `CreateList`, `RenameList`, `DeleteList`, `SetListWorkingDir` (name/dir/commitType) | `ListRepository` |
|
||||
| `ConfigMcpTools` | `GetListConfig`, `SetListConfig`, `SetTaskConfig` (model/system_prompt/agent_path) | `ListRepository`, `TaskRepository.UpdateAgentSettingsAsync` |
|
||||
| `RunHistoryMcpTools` | `ListRuns`, `GetRun`, `GetTaskLog` | `TaskRunRepository`, log file read |
|
||||
| `AgentMcpTools` | `ListAgents`, `RefreshAgents` | `AgentFileService.ScanAsync` |
|
||||
| `LifecycleMcpTools` | `ResetFailedTask` | `TaskResetService.ResetAsync` |
|
||||
| `AppSettingsMcpTools` | `GetAppSettings` | `AppSettingsRepository.GetAsync` |
|
||||
|
||||
(Exact class grouping may be tuned during planning, but each class stays small
|
||||
and single-purpose.)
|
||||
|
||||
## Architecture & wiring
|
||||
|
||||
The external MCP server is a **separate `WebApplication`** built in
|
||||
`Program.cs` (≈ lines 188–217) with its own DI container, distinct from the main
|
||||
SignalR app. Shared singletons (`HubBroadcaster`, `QueueService`,
|
||||
`ITaskStateService`, db factory, `WorkerConfig`) are injected by instance so both
|
||||
apps act on the same runtime state.
|
||||
|
||||
Each new tool class must be:
|
||||
1. Registered in the **external** builder (`externalBuilder.Services.AddScoped<…>()`),
|
||||
alongside any newly required services (`TaskRunRepository`, `AgentFileService`,
|
||||
`TaskResetService` + their dependencies).
|
||||
2. Registered as tools via additional `.WithTools<T>()` calls on the external
|
||||
`AddMcpServer()` chain.
|
||||
|
||||
No change to auth: the existing `ExternalMcpAuthMiddleware` (optional
|
||||
`X-ClaudeDo-Key`, loopback-only otherwise) covers all tools uniformly. No
|
||||
per-tool gating — the surface is read/observe + start, with the one borderline
|
||||
write (`ResetFailedTask`) being a normal retry affordance.
|
||||
|
||||
## Data flow
|
||||
|
||||
- **Start:** Claude calls e.g. `CreateList` → `SetListConfig` → `AddTask(queueImmediately: true)`. Writes go through `ListRepository` / `TaskStateService`, which wake the queue and broadcast `ListUpdated` / `TaskUpdated` so the UI reflects changes live.
|
||||
- **Observe:** Claude calls `ListTasks` / `GetTask` → `ListRuns` / `GetRun` → `GetTaskLog`. Pure reads from `TaskRepository` / `TaskRunRepository` and the log file at `TaskRunEntity.LogPath`.
|
||||
- **Mutations broadcast** the same SignalR events the hub raises, keeping the desktop UI in sync.
|
||||
|
||||
## DTOs
|
||||
|
||||
- `RunDto` — projection of `TaskRunEntity`: `Id`, `RunNumber`, `SessionId`, `IsRetry`, `ResultMarkdown`, `StructuredOutputJson`, `ErrorMarkdown`, `ExitCode`, `TurnCount`, `TokensIn`, `TokensOut`, `StartedAt`, `FinishedAt`.
|
||||
- `AgentDto` — from `AgentInfo` (`Name`, `Description`, `Path`).
|
||||
- `ListConfigDto` — `Model`, `SystemPrompt`, `AgentPath` (reuse the shape already used by the hub).
|
||||
- App-settings read reuses the existing `AppSettingsDto` shape (read-only subset is fine).
|
||||
- Log fetch returns the file contents as a string (with a size cap / tail option decided in planning).
|
||||
|
||||
## Error handling
|
||||
|
||||
Follow the existing `ExternalMcpService` convention: throw
|
||||
`InvalidOperationException` with a clear message for not-found / invalid-input /
|
||||
illegal-state (e.g. "List {id} not found", "Cannot reset a non-failed task").
|
||||
Reuse the guard patterns already present (required-field checks, status checks).
|
||||
`ResetFailedTask` must refuse non-`Failed` tasks.
|
||||
|
||||
## Testing
|
||||
|
||||
Extend `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` (and add
|
||||
sibling test files per new tool class) using the existing real-SQLite + real-git
|
||||
integration pattern:
|
||||
- List CRUD round-trips; rename/delete propagate; delete blocked/handled sensibly.
|
||||
- List + task config set/get round-trips; clearing all three fields removes list config (matches hub behavior).
|
||||
- Run history reads return correct projections; `GetTaskLog` returns file contents and errors cleanly when no log exists.
|
||||
- `ResetFailedTask` succeeds on a Failed task and refuses other statuses.
|
||||
- Agent listing reflects files on disk after refresh.
|
||||
- App-settings read returns current values.
|
||||
|
||||
## Doc cleanup (part of this work)
|
||||
|
||||
- `src/ClaudeDo.Worker/CLAUDE.md` — remove the stale `SetTaskTags` / `ListTags` /
|
||||
"AddTask (with tags)" claim; replace the External MCP tool inventory with the
|
||||
new surface.
|
||||
@@ -17,7 +17,9 @@
|
||||
<converters:EqStatusConverter x:Key="EqStatus"/>
|
||||
<converters:UpperCaseConverter x:Key="UpperCase"/>
|
||||
<converters:IconKeyConverter x:Key="IconKey"/>
|
||||
<converters:DotBrushConverter x:Key="DotBrush"/>
|
||||
<converters:DotBrushConverter x:Key="DotBrush"/>
|
||||
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
|
||||
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
|
||||
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 317 B After Width: | Height: | Size: 44 KiB |
@@ -19,8 +19,8 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
|
||||
|
||||
## DI Registration Pattern
|
||||
|
||||
- **Singletons**: SqliteConnectionFactory, all Repositories, WorkerClient, MainWindowViewModel, TaskListViewModel, TaskDetailViewModel, StatusBarViewModel
|
||||
- **Transients**: TaskEditorViewModel, ListEditorViewModel (created per dialog)
|
||||
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
|
||||
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
using Avalonia;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Releases;
|
||||
using ClaudeDo.Ui;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ClaudeDo.App;
|
||||
@@ -75,17 +79,45 @@ sealed class Program
|
||||
sc.AddSingleton<GitService>();
|
||||
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
||||
|
||||
// Release check + installer update
|
||||
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
|
||||
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
|
||||
sc.AddSingleton<InstallerLocator>();
|
||||
sc.AddSingleton<WorkerLocator>();
|
||||
sc.AddSingleton(sp =>
|
||||
{
|
||||
var releases = sp.GetRequiredService<IReleaseClient>();
|
||||
var informational = Assembly.GetEntryAssembly()?
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||
// Strip MinVer build metadata ("+sha") and any prerelease suffix for the update-compare.
|
||||
var version = (informational ?? "0.0.0").Split('+')[0];
|
||||
return new UpdateCheckService(releases, version);
|
||||
});
|
||||
|
||||
// ViewModels
|
||||
sc.AddTransient<WorktreeModalViewModel>();
|
||||
sc.AddTransient<Func<WorktreeModalViewModel>>(sp => () => sp.GetRequiredService<WorktreeModalViewModel>());
|
||||
sc.AddTransient<WorktreesOverviewModalViewModel>();
|
||||
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
||||
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
||||
sc.AddTransient<PrimeClaudeTabViewModel>();
|
||||
sc.AddTransient<SettingsModalViewModel>();
|
||||
sc.AddTransient<MergeModalViewModel>();
|
||||
sc.AddTransient<Func<MergeModalViewModel>>(sp => () => sp.GetRequiredService<MergeModalViewModel>());
|
||||
sc.AddTransient<ListSettingsModalViewModel>();
|
||||
sc.AddTransient<RepoImportModalViewModel>();
|
||||
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
|
||||
|
||||
// Islands shell VMs
|
||||
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||
new ListsIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp));
|
||||
sp,
|
||||
sp.GetRequiredService<WorkerClient>()));
|
||||
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
||||
new TasksIslandViewModel(sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>()));
|
||||
new TasksIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<WorkerClient>()));
|
||||
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
||||
new DetailsIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
|
||||
@@ -4,19 +4,21 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
|
||||
## Models
|
||||
|
||||
- **TaskEntity** — Id, ListId, Title, Description, Status (Manual|Queued|Running|Done|Failed), ScheduledFor, Result, LogPath, timestamps, CommitType
|
||||
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
|
||||
- **TagEntity** — Id (autoincrement), Name (unique)
|
||||
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath (all nullable)
|
||||
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
|
||||
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path)
|
||||
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
|
||||
|
||||
## Repositories
|
||||
|
||||
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim.
|
||||
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here.
|
||||
|
||||
- **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)
|
||||
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `TryCompleteParentAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
|
||||
- **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
|
||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
||||
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||
|
||||
## Infrastructure
|
||||
|
||||
@@ -31,7 +33,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `T
|
||||
|
||||
## Schema
|
||||
|
||||
6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual".
|
||||
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`).
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -14,4 +14,9 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ClaudeDo.Worker" />
|
||||
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -12,12 +12,12 @@ public class ClaudeDoDbContext : DbContext
|
||||
|
||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||
public DbSet<TagEntity> Tags => Set<TagEntity>();
|
||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
|
||||
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
|
||||
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -45,6 +45,13 @@ public class ClaudeDoDbContext : DbContext
|
||||
walCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Enable FK enforcement — SQLite defaults to OFF per connection.
|
||||
using (var fkCmd = conn.CreateCommand())
|
||||
{
|
||||
fkCmd.CommandText = "PRAGMA foreign_keys=ON;";
|
||||
fkCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// If the 'lists' table exists but __EFMigrationsHistory does not,
|
||||
// this is a pre-EF database. Baseline the InitialCreate migration.
|
||||
using (var cmd = conn.CreateCommand())
|
||||
|
||||
@@ -31,6 +31,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
||||
builder.Property(s => s.WorktreeAutoCleanupDays)
|
||||
.HasColumnName("worktree_auto_cleanup_days").IsRequired().HasDefaultValue(7);
|
||||
|
||||
builder.Property(s => s.RepoImportFolders)
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,16 +21,5 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
|
||||
.WithOne(c => c.List)
|
||||
.HasForeignKey<ListConfigEntity>(c => c.ListId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(l => l.Tags)
|
||||
.WithMany(tag => tag.Lists)
|
||||
.UsingEntity("list_tags",
|
||||
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
|
||||
r => r.HasOne(typeof(ListEntity)).WithMany().HasForeignKey("list_id").OnDelete(DeleteBehavior.Cascade),
|
||||
j =>
|
||||
{
|
||||
j.HasKey("list_id", "tag_id");
|
||||
j.ToTable("list_tags");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class PrimeScheduleEntityConfiguration : IEntityTypeConfiguration<PrimeScheduleEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PrimeScheduleEntity> builder)
|
||||
{
|
||||
builder.ToTable("prime_schedules");
|
||||
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
|
||||
|
||||
builder.Property(s => s.StartDate).HasColumnName("start_date").IsRequired();
|
||||
builder.Property(s => s.EndDate).HasColumnName("end_date").IsRequired();
|
||||
builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").IsRequired();
|
||||
builder.Property(s => s.WorkdaysOnly).HasColumnName("workdays_only").IsRequired().HasDefaultValue(true);
|
||||
builder.Property(s => s.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true);
|
||||
builder.Property(s => s.LastRunAt).HasColumnName("last_run_at");
|
||||
builder.Property(s => s.PromptOverride).HasColumnName("prompt_override");
|
||||
builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class TagEntityConfiguration : IEntityTypeConfiguration<TagEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TagEntity> builder)
|
||||
{
|
||||
builder.ToTable("tags");
|
||||
|
||||
builder.HasKey(t => t.Id);
|
||||
builder.Property(t => t.Id).HasColumnName("id").ValueGeneratedOnAdd();
|
||||
builder.Property(t => t.Name).HasColumnName("name").IsRequired();
|
||||
builder.HasIndex(t => t.Name).IsUnique();
|
||||
|
||||
builder.HasData(
|
||||
new TagEntity { Id = 1, Name = "agent" },
|
||||
new TagEntity { Id = 2, Name = "manual" });
|
||||
}
|
||||
}
|
||||
@@ -9,24 +9,53 @@ namespace ClaudeDo.Data.Configuration;
|
||||
public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
{
|
||||
private static string StatusToString(TaskStatus v)
|
||||
=> v == TaskStatus.Manual ? "manual"
|
||||
: v == TaskStatus.Queued ? "queued"
|
||||
: v == TaskStatus.Running ? "running"
|
||||
: v == TaskStatus.Done ? "done"
|
||||
: v == TaskStatus.Failed ? "failed"
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
=> v switch
|
||||
{
|
||||
TaskStatus.Idle => "idle",
|
||||
TaskStatus.Queued => "queued",
|
||||
TaskStatus.Running => "running",
|
||||
TaskStatus.Done => "done",
|
||||
TaskStatus.Failed => "failed",
|
||||
TaskStatus.Cancelled => "cancelled",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v)),
|
||||
};
|
||||
|
||||
private static TaskStatus StatusFromString(string v)
|
||||
=> v == "manual" ? TaskStatus.Manual
|
||||
: v == "queued" ? TaskStatus.Queued
|
||||
: v == "running" ? TaskStatus.Running
|
||||
: v == "done" ? TaskStatus.Done
|
||||
: v == "failed" ? TaskStatus.Failed
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
=> v switch
|
||||
{
|
||||
"idle" => TaskStatus.Idle,
|
||||
"queued" => TaskStatus.Queued,
|
||||
"running" => TaskStatus.Running,
|
||||
"done" => TaskStatus.Done,
|
||||
"failed" => TaskStatus.Failed,
|
||||
"cancelled" => TaskStatus.Cancelled,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v)),
|
||||
};
|
||||
|
||||
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
|
||||
new(v => StatusToString(v), v => StatusFromString(v));
|
||||
|
||||
private static string PhaseToString(PlanningPhase v)
|
||||
=> v switch
|
||||
{
|
||||
PlanningPhase.None => "none",
|
||||
PlanningPhase.Active => "active",
|
||||
PlanningPhase.Finalized => "finalized",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v)),
|
||||
};
|
||||
|
||||
private static PlanningPhase PhaseFromString(string v)
|
||||
=> v switch
|
||||
{
|
||||
"none" => PlanningPhase.None,
|
||||
"active" => PlanningPhase.Active,
|
||||
"finalized" => PlanningPhase.Finalized,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v)),
|
||||
};
|
||||
|
||||
private static readonly ValueConverter<PlanningPhase, string> PhaseConverter =
|
||||
new(v => PhaseToString(v), v => PhaseFromString(v));
|
||||
|
||||
public void Configure(EntityTypeBuilder<TaskEntity> builder)
|
||||
{
|
||||
builder.ToTable("tasks");
|
||||
@@ -38,6 +67,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
builder.Property(t => t.Description).HasColumnName("description");
|
||||
builder.Property(t => t.Status).HasColumnName("status").IsRequired()
|
||||
.HasConversion(StatusConverter);
|
||||
builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired()
|
||||
.HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None);
|
||||
builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id");
|
||||
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
|
||||
builder.Property(t => t.Result).HasColumnName("result");
|
||||
builder.Property(t => t.LogPath).HasColumnName("log_path");
|
||||
@@ -51,6 +83,25 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false);
|
||||
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
|
||||
builder.Property(t => t.Notes).HasColumnName("notes");
|
||||
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||
|
||||
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
|
||||
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
|
||||
builder.Property(t => t.PlanningSessionToken).HasColumnName("planning_session_token");
|
||||
builder.Property(t => t.PlanningFinalizedAt).HasColumnName("planning_finalized_at");
|
||||
|
||||
builder.Property(t => t.CreatedBy).HasColumnName("created_by");
|
||||
|
||||
builder.HasOne(t => t.Parent)
|
||||
.WithMany(t => t.Children)
|
||||
.HasForeignKey(t => t.ParentTaskId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// BlockedBy: predecessor in a sequential chain. SetNull on delete so child becomes pickable.
|
||||
builder.HasOne<TaskEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.BlockedByTaskId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.HasOne(t => t.List)
|
||||
.WithMany(l => l.Tasks)
|
||||
@@ -61,18 +112,10 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
.WithOne(w => w.Task)
|
||||
.HasForeignKey<WorktreeEntity>(w => w.TaskId);
|
||||
|
||||
builder.HasMany(t => t.Tags)
|
||||
.WithMany(tag => tag.Tasks)
|
||||
.UsingEntity("task_tags",
|
||||
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
|
||||
r => r.HasOne(typeof(TaskEntity)).WithMany().HasForeignKey("task_id").OnDelete(DeleteBehavior.Cascade),
|
||||
j =>
|
||||
{
|
||||
j.HasKey("task_id", "tag_id");
|
||||
j.ToTable("task_tags");
|
||||
});
|
||||
|
||||
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
|
||||
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
||||
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
|
||||
builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
|
||||
builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");
|
||||
}
|
||||
}
|
||||
|
||||
14
src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs
Normal file
14
src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering.Filters;
|
||||
|
||||
public sealed class ReviewFilter : ITaskListFilter
|
||||
{
|
||||
public string Id => "virtual:review";
|
||||
public bool Matches(TaskEntity t) =>
|
||||
t.Status == TaskStatus.Done &&
|
||||
t.Worktree is { State: WorktreeState.Active };
|
||||
public bool ShouldCount(TaskEntity t) => Matches(t);
|
||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||
}
|
||||
16
src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs
Normal file
16
src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Filter for a smart list keyed off a boolean/nullable task flag
|
||||
/// (My Day, Important, Planned). Counts only non-done matches.
|
||||
/// </summary>
|
||||
public sealed class SmartFlagFilter(string id, Func<TaskEntity, bool> flag) : ITaskListFilter
|
||||
{
|
||||
public string Id => id;
|
||||
public bool Matches(TaskEntity t) => flag(t);
|
||||
public bool ShouldCount(TaskEntity t) => flag(t) && t.Status != TaskStatus.Done;
|
||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||
}
|
||||
18
src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs
Normal file
18
src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Virtual list filter matching tasks by a single status (Queued, Running).
|
||||
/// Planning parents appear contextually when they host a matching child.
|
||||
/// </summary>
|
||||
public sealed class StatusFilter(string id, TaskStatus status) : ITaskListFilter
|
||||
{
|
||||
public string Id => id;
|
||||
public bool Matches(TaskEntity t) => t.Status == status;
|
||||
public bool ShouldCount(TaskEntity t) => t.Status == status;
|
||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
|
||||
PlanningRules.IsPlanningParent(t) &&
|
||||
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
|
||||
}
|
||||
24
src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs
Normal file
24
src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Filter for any user-defined list. Constructed on demand from the list id —
|
||||
/// one instance per list.
|
||||
/// </summary>
|
||||
public sealed class UserListFilter : ITaskListFilter
|
||||
{
|
||||
private readonly string _listId;
|
||||
|
||||
public UserListFilter(string listId)
|
||||
{
|
||||
_listId = listId;
|
||||
Id = $"user:{listId}";
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public bool Matches(TaskEntity t) => t.ListId == _listId;
|
||||
public bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
|
||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||
}
|
||||
26
src/ClaudeDo.Data/Filtering/Interfaces/ITaskListFilter.cs
Normal file
26
src/ClaudeDo.Data/Filtering/Interfaces/ITaskListFilter.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering;
|
||||
|
||||
/// <summary>
|
||||
/// Strategy that defines which tasks belong to a single list. One implementation
|
||||
/// per list kind; consumers (counters, list loader) ask the registry for the
|
||||
/// right strategy and never branch on the list id themselves.
|
||||
/// </summary>
|
||||
public interface ITaskListFilter
|
||||
{
|
||||
/// <summary>The list id this filter applies to (e.g. "virtual:queued", "user:abc").</summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
|
||||
bool Matches(TaskEntity t);
|
||||
|
||||
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
|
||||
bool ShouldCount(TaskEntity t);
|
||||
|
||||
/// <summary>
|
||||
/// True if <paramref name="t"/> is shown as a contextual row (not a primary citizen,
|
||||
/// but appears to host children that match). Default: nothing extra.
|
||||
/// </summary>
|
||||
bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||
}
|
||||
27
src/ClaudeDo.Data/Filtering/PlanningRules.cs
Normal file
27
src/ClaudeDo.Data/Filtering/PlanningRules.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering;
|
||||
|
||||
/// <summary>
|
||||
/// Shared predicates that capture planning-hierarchy semantics. Any new rule
|
||||
/// involving parents, children, or planning phases belongs here.
|
||||
/// </summary>
|
||||
public static class PlanningRules
|
||||
{
|
||||
public static bool IsPlanningParent(TaskEntity t) =>
|
||||
t.PlanningPhase != PlanningPhase.None;
|
||||
|
||||
public static bool HasMatchingChild(
|
||||
TaskEntity parent,
|
||||
IReadOnlyList<TaskEntity> all,
|
||||
Func<TaskEntity, bool> childPredicate)
|
||||
{
|
||||
for (var i = 0; i < all.Count; i++)
|
||||
{
|
||||
var c = all[i];
|
||||
if (c.ParentTaskId == parent.Id && childPredicate(c))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
39
src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs
Normal file
39
src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using ClaudeDo.Data.Filtering.Filters;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a list id (e.g. "virtual:queued", "user:abc") to the filter that
|
||||
/// owns its semantics. Smart and virtual filters are singletons; user-list
|
||||
/// filters are constructed on demand from the id.
|
||||
/// </summary>
|
||||
public sealed class TaskListFilterRegistry
|
||||
{
|
||||
public const string UserListPrefix = "user:";
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, ITaskListFilter> BuiltIn =
|
||||
new Dictionary<string, ITaskListFilter>(StringComparer.Ordinal)
|
||||
{
|
||||
["smart:my-day"] = new SmartFlagFilter("smart:my-day", t => t.IsMyDay),
|
||||
["smart:important"] = new SmartFlagFilter("smart:important", t => t.IsStarred),
|
||||
["smart:planned"] = new SmartFlagFilter("smart:planned", t => t.ScheduledFor != null),
|
||||
["virtual:queued"] = new StatusFilter("virtual:queued", TaskStatus.Queued),
|
||||
["virtual:running"] = new StatusFilter("virtual:running", TaskStatus.Running),
|
||||
["virtual:review"] = new ReviewFilter(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a filter for a list id, or null if the id is unknown.
|
||||
/// </summary>
|
||||
public ITaskListFilter? Resolve(string listId)
|
||||
{
|
||||
if (BuiltIn.TryGetValue(listId, out var f)) return f;
|
||||
if (listId.StartsWith(UserListPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var inner = listId[UserListPrefix.Length..];
|
||||
return string.IsNullOrEmpty(inner) ? null : new UserListFilter(inner);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,15 @@ public sealed class GitService
|
||||
return stdout;
|
||||
}
|
||||
|
||||
public async Task<string> GetCommittedFilesAsync(string worktreePath, string baseCommit, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
||||
["diff", "--name-status", $"{baseCommit}..HEAD"], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git diff --name-status failed (exit {exitCode}): {stderr}");
|
||||
return stdout;
|
||||
}
|
||||
|
||||
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
|
||||
@@ -97,6 +106,15 @@ public sealed class GitService
|
||||
return stdout.Trim();
|
||||
}
|
||||
|
||||
public async Task<string> GetFileDiffAsync(string worktreePath, string? baseCommit, string relativePath, CancellationToken ct = default)
|
||||
{
|
||||
string[] args = string.IsNullOrEmpty(baseCommit)
|
||||
? ["diff", "--", relativePath]
|
||||
: ["diff", $"{baseCommit}..HEAD", "--", relativePath];
|
||||
var (_, stdout, _) = await RunGitAsync(worktreePath, args, ct);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
public async Task WorktreeRemoveAsync(string repoDir, string worktreePath, bool force = false, CancellationToken ct = default)
|
||||
{
|
||||
var args = new List<string> { "worktree", "remove" };
|
||||
@@ -151,6 +169,73 @@ public sealed class GitService
|
||||
throw new InvalidOperationException($"git branch {flag} failed (exit {exitCode}): {stderr}");
|
||||
}
|
||||
|
||||
public async Task<string> GetCurrentBranchAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||
["symbolic-ref", "--short", "HEAD"], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git symbolic-ref --short HEAD failed (exit {exitCode}): {stderr}");
|
||||
return stdout.Trim();
|
||||
}
|
||||
|
||||
public async Task CheckoutBranchAsync(string repoDir, string branchName, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["checkout", branchName], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git checkout '{branchName}' failed (exit {exitCode}): {stderr}");
|
||||
}
|
||||
|
||||
public async Task<List<string>> ListLocalBranchesAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||
["branch", "--format=%(refname:short)"], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git branch --format failed (exit {exitCode}): {stderr}");
|
||||
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(s => s.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> IsMidMergeAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
|
||||
if (exitCode != 0) return false;
|
||||
var gitDir = stdout.Trim();
|
||||
if (!Path.IsPathRooted(gitDir))
|
||||
gitDir = Path.Combine(repoDir, gitDir);
|
||||
return File.Exists(Path.Combine(gitDir, "MERGE_HEAD"));
|
||||
}
|
||||
|
||||
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
|
||||
string repoDir, string sourceBranch, string message, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["merge", "--no-ff", "-m", message, sourceBranch], ct);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
public async Task MergeAbortAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--abort"], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git merge --abort failed (exit {exitCode}): {stderr}");
|
||||
}
|
||||
|
||||
public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||
["diff", "--name-only", "--diff-filter=U"], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git diff --diff-filter=U failed (exit {exitCode}): {stderr}");
|
||||
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(s => s.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
|
||||
@@ -169,6 +254,9 @@ public sealed class GitService
|
||||
RedirectStandardInput = stdinData is not null,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
StandardInputEncoding = stdinData is not null ? Encoding.UTF8 : null,
|
||||
};
|
||||
psi.ArgumentList.Add("-C");
|
||||
psi.ArgumentList.Add(workDir);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTaskSortOrder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "sort_order",
|
||||
table: "tasks",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
// Backfill existing rows with a per-list dense order (0..N-1) by creation time
|
||||
// so today's UI order is preserved after the migration.
|
||||
migrationBuilder.Sql("""
|
||||
WITH ordered AS (
|
||||
SELECT id, (row_number() OVER (PARTITION BY list_id ORDER BY created_at) - 1) AS rn
|
||||
FROM tasks
|
||||
)
|
||||
UPDATE tasks SET sort_order = (SELECT rn FROM ordered WHERE ordered.id = tasks.id);
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_tasks_list_sort",
|
||||
table: "tasks",
|
||||
columns: new[] { "list_id", "sort_order" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "idx_tasks_list_sort",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "sort_order",
|
||||
table: "tasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPlanningSupport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "parent_task_id",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "planning_finalized_at",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "planning_session_id",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "planning_session_token",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_tasks_parent_task_id",
|
||||
table: "tasks",
|
||||
column: "parent_task_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_tasks_tasks_parent_task_id",
|
||||
table: "tasks",
|
||||
column: "parent_task_id",
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_tasks_tasks_parent_task_id",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "idx_tasks_parent_task_id",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "parent_task_id",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "planning_finalized_at",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "planning_session_id",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "planning_session_token",
|
||||
table: "tasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTaskCreatedBy : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "created_by",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "created_by",
|
||||
table: "tasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPlanningPhaseAndBlockedBy : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "blocked_by_task_id",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "planning_phase",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "none");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
column: "default_permission_mode",
|
||||
value: "auto");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_tasks_blocked_by",
|
||||
table: "tasks",
|
||||
column: "blocked_by_task_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_tasks_tasks_blocked_by_task_id",
|
||||
table: "tasks",
|
||||
column: "blocked_by_task_id",
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_tasks_tasks_blocked_by_task_id",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "idx_tasks_blocked_by",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "blocked_by_task_id",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "planning_phase",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
column: "default_permission_mode",
|
||||
value: "bypassPermissions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RetireLegacyTaskStatus : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// manual / draft -> idle
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'idle' WHERE status IN ('manual', 'draft');");
|
||||
|
||||
// planning -> idle + planning_phase=active
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'idle', planning_phase = 'active' WHERE status = 'planning';");
|
||||
|
||||
// planned -> idle + planning_phase=finalized
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'idle', planning_phase = 'finalized' WHERE status = 'planned';");
|
||||
|
||||
// waiting -> queued + blocked_by_task_id derived from sort_order chain.
|
||||
// SQLite 3.25+ supports window functions (LAG).
|
||||
migrationBuilder.Sql(@"
|
||||
WITH ordered AS (
|
||||
SELECT id,
|
||||
LAG(id) OVER (PARTITION BY parent_task_id ORDER BY sort_order, created_at) AS prev_id
|
||||
FROM tasks
|
||||
WHERE status = 'waiting'
|
||||
)
|
||||
UPDATE tasks
|
||||
SET status = 'queued',
|
||||
blocked_by_task_id = (SELECT prev_id FROM ordered WHERE ordered.id = tasks.id)
|
||||
WHERE id IN (SELECT id FROM ordered);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Best-effort and lossy: cancelled is folded back into failed,
|
||||
// (idle, finalized) -> planned, (idle, active) -> planning,
|
||||
// queued + blocked_by_task_id != null -> waiting.
|
||||
// Manual/Draft distinction is unrecoverable — anything previously
|
||||
// 'manual' or 'draft' stays 'idle' on the way back.
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'failed' WHERE status = 'cancelled';");
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'planned' WHERE status = 'idle' AND planning_phase = 'finalized';");
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'planning' WHERE status = 'idle' AND planning_phase = 'active';");
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'waiting', blocked_by_task_id = NULL WHERE status = 'queued' AND blocked_by_task_id IS NOT NULL;");
|
||||
}
|
||||
}
|
||||
}
|
||||
679
src/ClaudeDo.Data/Migrations/20260428064951_AddPrimeSchedules.Designer.cs
generated
Normal file
679
src/ClaudeDo.Data/Migrations/20260428064951_AddPrimeSchedules.Designer.cs
generated
Normal file
@@ -0,0 +1,679 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260428064951_AddPrimeSchedules")]
|
||||
partial class AddPrimeSchedules
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.Property<bool>("WorkdaysOnly")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("workdays_only");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("tags", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1L,
|
||||
Name = "agent"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2L,
|
||||
Name = "manual"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("list_tags", b =>
|
||||
{
|
||||
b.Property<string>("list_id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("tag_id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("list_id", "tag_id");
|
||||
|
||||
b.HasIndex("tag_id");
|
||||
|
||||
b.ToTable("list_tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("task_tags", b =>
|
||||
{
|
||||
b.Property<string>("task_id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("tag_id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("task_id", "tag_id");
|
||||
|
||||
b.HasIndex("tag_id");
|
||||
|
||||
b.ToTable("task_tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("list_tags", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("list_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("tag_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("task_tags", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("tag_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("task_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPrimeSchedules : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "prime_schedules",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
start_date = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
end_date = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
time_of_day = table.Column<TimeSpan>(type: "TEXT", nullable: false),
|
||||
workdays_only = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true),
|
||||
enabled = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true),
|
||||
last_run_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
|
||||
prompt_override = table.Column<string>(type: "TEXT", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_prime_schedules", x => x.id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "prime_schedules");
|
||||
}
|
||||
}
|
||||
}
|
||||
115
src/ClaudeDo.Data/Migrations/20260519044715_RemoveTags.cs
Normal file
115
src/ClaudeDo.Data/Migrations/20260519044715_RemoveTags.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveTags : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "list_tags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "task_tags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tags");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tags",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
name = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_tags", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "list_tags",
|
||||
columns: table => new
|
||||
{
|
||||
list_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_list_tags", x => new { x.list_id, x.tag_id });
|
||||
table.ForeignKey(
|
||||
name: "FK_list_tags_lists_list_id",
|
||||
column: x => x.list_id,
|
||||
principalTable: "lists",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_list_tags_tags_tag_id",
|
||||
column: x => x.tag_id,
|
||||
principalTable: "tags",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "task_tags",
|
||||
columns: table => new
|
||||
{
|
||||
task_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_task_tags", x => new { x.task_id, x.tag_id });
|
||||
table.ForeignKey(
|
||||
name: "FK_task_tags_tags_tag_id",
|
||||
column: x => x.tag_id,
|
||||
principalTable: "tags",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_task_tags_tasks_task_id",
|
||||
column: x => x.task_id,
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "tags",
|
||||
columns: new[] { "id", "name" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1L, "agent" },
|
||||
{ 2L, "manual" }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_list_tags_tag_id",
|
||||
table: "list_tags",
|
||||
column: "tag_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_tags_name",
|
||||
table: "tags",
|
||||
column: "name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_task_tags_tag_id",
|
||||
table: "task_tags",
|
||||
column: "tag_id");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRepoImportFolders : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "repo_import_folders",
|
||||
table: "app_settings",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
column: "repo_import_folders",
|
||||
value: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "repo_import_folders",
|
||||
table: "app_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
@@ -82,9 +86,9 @@ namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 30,
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "bypassPermissions",
|
||||
DefaultPermissionMode = "auto",
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
@@ -145,6 +149,53 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.Property<bool>("WorkdaysOnly")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("workdays_only");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -183,38 +234,6 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("tags", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1L,
|
||||
Name = "agent"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2L,
|
||||
Name = "manual"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -225,6 +244,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -236,6 +259,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
@@ -273,6 +300,29 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
@@ -281,6 +331,12 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
@@ -301,12 +357,21 @@ namespace ClaudeDo.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
@@ -433,36 +498,6 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("list_tags", b =>
|
||||
{
|
||||
b.Property<string>("list_id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("tag_id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("list_id", "tag_id");
|
||||
|
||||
b.HasIndex("tag_id");
|
||||
|
||||
b.ToTable("list_tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("task_tags", b =>
|
||||
{
|
||||
b.Property<string>("task_id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("tag_id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("task_id", "tag_id");
|
||||
|
||||
b.HasIndex("tag_id");
|
||||
|
||||
b.ToTable("task_tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
@@ -487,13 +522,25 @@ namespace ClaudeDo.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
@@ -518,36 +565,6 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("list_tags", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("list_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("tag_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("task_tags", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("tag_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("task_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
@@ -557,6 +574,8 @@ namespace ClaudeDo.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
@@ -8,11 +8,14 @@ public sealed class AppSettingsEntity
|
||||
|
||||
public string DefaultClaudeInstructions { get; set; } = string.Empty;
|
||||
public string DefaultModel { get; set; } = "sonnet";
|
||||
public int DefaultMaxTurns { get; set; } = 30;
|
||||
public string DefaultPermissionMode { get; set; } = "bypassPermissions";
|
||||
public int DefaultMaxTurns { get; set; } = 100;
|
||||
public string DefaultPermissionMode { get; set; } = "auto";
|
||||
|
||||
public string WorktreeStrategy { get; set; } = "sibling";
|
||||
public string? CentralWorktreeRoot { get; set; }
|
||||
public bool WorktreeAutoCleanupEnabled { get; set; }
|
||||
public int WorktreeAutoCleanupDays { get; set; } = 7;
|
||||
|
||||
// JSON array of parent folders remembered by the repo-import modal.
|
||||
public string? RepoImportFolders { get; set; }
|
||||
}
|
||||
|
||||
11
src/ClaudeDo.Data/Models/CommitTypeRegistry.cs
Normal file
11
src/ClaudeDo.Data/Models/CommitTypeRegistry.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public static class CommitTypeRegistry
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Types = new[]
|
||||
{
|
||||
"chore", "feat", "fix", "refactor", "docs", "test", "ci", "perf", "style", "build",
|
||||
};
|
||||
|
||||
public const string DefaultType = "chore";
|
||||
}
|
||||
@@ -6,10 +6,9 @@ public sealed class ListEntity
|
||||
public required string Name { get; set; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
public string? WorkingDir { get; set; }
|
||||
public string DefaultCommitType { get; set; } = "chore";
|
||||
public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType;
|
||||
|
||||
// Navigation properties
|
||||
public ListConfigEntity? Config { get; set; }
|
||||
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
|
||||
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
|
||||
}
|
||||
|
||||
12
src/ClaudeDo.Data/Models/ModelRegistry.cs
Normal file
12
src/ClaudeDo.Data/Models/ModelRegistry.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public static class ModelRegistry
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku" };
|
||||
|
||||
public const string DefaultAlias = "sonnet";
|
||||
public const string PlanningAlias = "opus";
|
||||
|
||||
public const string ListDefaultSentinel = "(default)";
|
||||
public const string TaskInheritSentinel = "(inherit)";
|
||||
}
|
||||
11
src/ClaudeDo.Data/Models/PermissionModeRegistry.cs
Normal file
11
src/ClaudeDo.Data/Models/PermissionModeRegistry.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public static class PermissionModeRegistry
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Modes = new[]
|
||||
{
|
||||
"auto", "bypassPermissions", "acceptEdits", "plan", "default",
|
||||
};
|
||||
|
||||
public const string DefaultMode = "auto";
|
||||
}
|
||||
14
src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs
Normal file
14
src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public sealed class PrimeScheduleEntity
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
public TimeSpan TimeOfDay { get; set; }
|
||||
public bool WorkdaysOnly { get; set; } = true;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public DateTimeOffset? LastRunAt { get; set; }
|
||||
public string? PromptOverride { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public sealed class TagEntity
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public ICollection<ListEntity> Lists { get; set; } = new List<ListEntity>();
|
||||
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
|
||||
}
|
||||
@@ -2,11 +2,19 @@ namespace ClaudeDo.Data.Models;
|
||||
|
||||
public enum TaskStatus
|
||||
{
|
||||
Manual,
|
||||
Idle,
|
||||
Queued,
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
public enum PlanningPhase
|
||||
{
|
||||
None,
|
||||
Active,
|
||||
Finalized,
|
||||
}
|
||||
|
||||
public sealed class TaskEntity
|
||||
@@ -15,25 +23,37 @@ public sealed class TaskEntity
|
||||
public required string ListId { get; init; }
|
||||
public required string Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public TaskStatus Status { get; set; } = TaskStatus.Manual;
|
||||
public TaskStatus Status { get; set; } = TaskStatus.Idle;
|
||||
public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None;
|
||||
public string? BlockedByTaskId { get; set; }
|
||||
public DateTime? ScheduledFor { get; set; }
|
||||
public string? Result { get; set; }
|
||||
public string? LogPath { get; set; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? FinishedAt { get; set; }
|
||||
public string CommitType { get; set; } = "chore";
|
||||
public string CommitType { get; set; } = CommitTypeRegistry.DefaultType;
|
||||
public string? Model { get; set; }
|
||||
public string? SystemPrompt { get; set; }
|
||||
public string? AgentPath { get; set; }
|
||||
public bool IsStarred { get; set; }
|
||||
public bool IsMyDay { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public string? ParentTaskId { get; set; }
|
||||
public string? PlanningSessionId { get; set; }
|
||||
public string? PlanningSessionToken { get; set; }
|
||||
public DateTime? PlanningFinalizedAt { get; set; }
|
||||
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public ListEntity List { get; set; } = null!;
|
||||
public WorktreeEntity? Worktree { get; set; }
|
||||
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
|
||||
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
|
||||
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
|
||||
|
||||
public TaskEntity? Parent { get; set; }
|
||||
public ICollection<TaskEntity> Children { get; set; } = new List<TaskEntity>();
|
||||
}
|
||||
|
||||
9
src/ClaudeDo.Data/Models/WorkerLogLevel.cs
Normal file
9
src/ClaudeDo.Data/Models/WorkerLogLevel.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public enum WorkerLogLevel
|
||||
{
|
||||
Info,
|
||||
Success,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
58
src/ClaudeDo.Data/PromptFiles.cs
Normal file
58
src/ClaudeDo.Data/PromptFiles.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
public enum PromptKind { System, Planning, Agent }
|
||||
|
||||
public static class PromptFiles
|
||||
{
|
||||
public static string Root => Path.Combine(Paths.AppDataRoot(), "prompts");
|
||||
|
||||
public static string PathFor(PromptKind kind) => kind switch
|
||||
{
|
||||
PromptKind.System => Path.Combine(Root, "system.md"),
|
||||
PromptKind.Planning => Path.Combine(Root, "planning.md"),
|
||||
PromptKind.Agent => Path.Combine(Root, "agent.md"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||
};
|
||||
|
||||
public static void EnsureExists(PromptKind kind)
|
||||
{
|
||||
Directory.CreateDirectory(Root);
|
||||
var path = PathFor(kind);
|
||||
if (File.Exists(path)) return;
|
||||
File.WriteAllText(path, DefaultFor(kind));
|
||||
}
|
||||
|
||||
public static string? ReadOrNull(PromptKind kind)
|
||||
{
|
||||
var path = PathFor(kind);
|
||||
if (!File.Exists(path)) return null;
|
||||
var content = File.ReadAllText(path).Trim();
|
||||
return string.IsNullOrEmpty(content) ? null : content;
|
||||
}
|
||||
|
||||
private static string DefaultFor(PromptKind kind) => kind switch
|
||||
{
|
||||
PromptKind.System =>
|
||||
"# System Prompt\n\n" +
|
||||
"Baseline instructions appended to every task run.\n" +
|
||||
"Edit this file to inject project-wide rules (style, conventions, hard constraints).\n",
|
||||
PromptKind.Planning =>
|
||||
"You are a planning assistant for ClaudeDo.\n" +
|
||||
"Your role is to help break down a task into smaller, actionable subtasks.\n" +
|
||||
"Your final goal WILL ALWAYS be the creation of Subtasks.\n\n" +
|
||||
"ALWAYS invoke the `superpowers:brainstorming` skill via the Skill tool at the\n" +
|
||||
"start of every planning session, and follow its process end-to-end. It guides\n" +
|
||||
"you through clarifying questions, approach exploration, and design approval\n" +
|
||||
"BEFORE any subtasks are created. Do not create child tasks until the user has\n" +
|
||||
"approved a design.\n\n" +
|
||||
"NEVER change files yourself.\n\n" +
|
||||
"ALWAYS use the available MCP tools (mcp__claudedo__*) to create child tasks once\n" +
|
||||
"the design is approved. When you are done planning, finalize the session.\n\n" +
|
||||
"Be concise and focused. Each subtask should be independently executable.\n",
|
||||
PromptKind.Agent =>
|
||||
"# Agent Prompt\n\n" +
|
||||
"Appended to the system prompt for tasks tagged \"agent\" (auto-queued runs).\n" +
|
||||
"Use this for autonomous-execution rules that don't apply to manual runs.\n",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -22,7 +23,7 @@ public sealed class AppSettingsRepository
|
||||
return row;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default)
|
||||
private async Task<AppSettingsEntity> GetOrCreateTrackedRowAsync(CancellationToken ct)
|
||||
{
|
||||
var row = await _context.AppSettings
|
||||
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
|
||||
@@ -31,12 +32,18 @@ public sealed class AppSettingsRepository
|
||||
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
||||
_context.AppSettings.Add(row);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default)
|
||||
{
|
||||
var row = await GetOrCreateTrackedRowAsync(ct);
|
||||
|
||||
row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty;
|
||||
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
|
||||
row.DefaultMaxTurns = updated.DefaultMaxTurns;
|
||||
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
|
||||
? "bypassPermissions" : updated.DefaultPermissionMode;
|
||||
? "auto" : updated.DefaultPermissionMode;
|
||||
row.WorktreeStrategy = string.IsNullOrWhiteSpace(updated.WorktreeStrategy) ? "sibling" : updated.WorktreeStrategy;
|
||||
row.CentralWorktreeRoot = string.IsNullOrWhiteSpace(updated.CentralWorktreeRoot)
|
||||
? null : updated.CentralWorktreeRoot;
|
||||
@@ -45,4 +52,25 @@ public sealed class AppSettingsRepository
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetRepoImportFoldersAsync(CancellationToken ct = default)
|
||||
{
|
||||
var json = await _context.AppSettings.AsNoTracking()
|
||||
.Where(s => s.Id == AppSettingsEntity.SingletonId)
|
||||
.Select(s => s.RepoImportFolders)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json)) return new List<string>();
|
||||
try { return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>(); }
|
||||
catch (JsonException) { return new List<string>(); }
|
||||
}
|
||||
|
||||
public async Task SetRepoImportFoldersAsync(IEnumerable<string> folders, CancellationToken ct = default)
|
||||
{
|
||||
var list = folders.ToList();
|
||||
var row = await GetOrCreateTrackedRowAsync(ct);
|
||||
|
||||
row.RepoImportFolders = list.Count == 0 ? null : JsonSerializer.Serialize(list);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
18
src/ClaudeDo.Data/Repositories/DiscardPlanningOutcome.cs
Normal file
18
src/ClaudeDo.Data/Repositories/DiscardPlanningOutcome.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public enum DiscardPlanningResult
|
||||
{
|
||||
/// <summary>Planning state cleared, children handled.</summary>
|
||||
Discarded,
|
||||
/// <summary>Parent not found or not in <c>PlanningPhase.Active</c>.</summary>
|
||||
NotInPlanning,
|
||||
/// <summary>At least one child is <c>Queued</c> and the caller did not opt in to auto-dequeue.</summary>
|
||||
BlockedByQueuedChildren,
|
||||
/// <summary>At least one child is <c>Running</c>; user must cancel it before discarding.</summary>
|
||||
BlockedByRunningChildren,
|
||||
}
|
||||
|
||||
public readonly record struct DiscardPlanningOutcome(
|
||||
DiscardPlanningResult Result,
|
||||
int QueuedChildrenCount,
|
||||
int RunningChildrenCount);
|
||||
@@ -36,38 +36,6 @@ public sealed class ListRepository
|
||||
return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Lists
|
||||
.Where(l => l.Id == listId)
|
||||
.SelectMany(l => l.Tags)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
|
||||
{
|
||||
var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
|
||||
if (list is null) return;
|
||||
var tag = await _context.Tags.FindAsync([tagId], ct);
|
||||
if (tag is not null && !list.Tags.Any(t => t.Id == tagId))
|
||||
{
|
||||
list.Tags.Add(tag);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
|
||||
{
|
||||
var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
|
||||
if (list is null) return;
|
||||
var tag = list.Tags.FirstOrDefault(t => t.Id == tagId);
|
||||
if (tag is not null)
|
||||
{
|
||||
list.Tags.Remove(tag);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
|
||||
@@ -88,4 +56,12 @@ public sealed class ListRepository
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteConfigAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
var affected = await _context.ListConfigs
|
||||
.Where(c => c.ListId == listId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
return affected > 0;
|
||||
}
|
||||
}
|
||||
|
||||
58
src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs
Normal file
58
src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
// src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class PrimeScheduleRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public PrimeScheduleRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _context.PrimeSchedules.AsNoTracking()
|
||||
.OrderBy(s => s.StartDate)
|
||||
.ToListAsync(ct);
|
||||
return rows.OrderBy(s => s.StartDate).ThenBy(s => s.TimeOfDay).ToList();
|
||||
}
|
||||
|
||||
public async Task<PrimeScheduleEntity?> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
await _context.PrimeSchedules.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
|
||||
public async Task UpsertAsync(PrimeScheduleEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == entity.Id, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
_context.PrimeSchedules.Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.StartDate = entity.StartDate;
|
||||
existing.EndDate = entity.EndDate;
|
||||
existing.TimeOfDay = entity.TimeOfDay;
|
||||
existing.WorkdaysOnly = entity.WorkdaysOnly;
|
||||
existing.Enabled = entity.Enabled;
|
||||
existing.PromptOverride = entity.PromptOverride;
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var row = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
if (row is null) return;
|
||||
_context.PrimeSchedules.Remove(row);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task UpdateLastRunAsync(Guid id, DateTimeOffset when, CancellationToken ct = default)
|
||||
{
|
||||
var row = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
if (row is null) return;
|
||||
row.LastRunAt = when;
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class TagRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public TagRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<long> GetOrCreateAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
|
||||
if (existing is not null)
|
||||
return existing.Id;
|
||||
|
||||
var tag = new TagEntity { Name = name };
|
||||
_context.Tags.Add(tag);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return tag.Id;
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,22 @@ public sealed class TaskRepository
|
||||
|
||||
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
// Append at bottom of the list by default: SortOrder = max(listId) + 1.
|
||||
var maxSort = await _context.Tasks
|
||||
.Where(t => t.ListId == entity.ListId)
|
||||
.Select(t => (int?)t.SortOrder)
|
||||
.MaxAsync(ct);
|
||||
entity.SortOrder = (maxSort ?? -1) + 1;
|
||||
|
||||
_context.Tasks.Add(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
var tracked = _context.Tasks.Local.FirstOrDefault(t => t.Id == entity.Id);
|
||||
if (tracked is not null && !ReferenceEquals(tracked, entity))
|
||||
_context.Entry(tracked).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
||||
_context.Tasks.Update(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
@@ -38,19 +48,50 @@ public sealed class TaskRepository
|
||||
{
|
||||
return await _context.Tasks
|
||||
.Where(t => t.ListId == listId)
|
||||
.OrderBy(t => t.CreatedAt)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renumbers tasks in a list to 0..N-1 according to <paramref name="orderedTaskIds"/>.
|
||||
/// Ids not belonging to the list are ignored; ids missing from the list are untouched.
|
||||
/// </summary>
|
||||
public async Task ReorderAsync(string listId, IReadOnlyList<string> orderedTaskIds, CancellationToken ct = default)
|
||||
{
|
||||
if (orderedTaskIds.Count == 0) return;
|
||||
|
||||
var idSet = orderedTaskIds.ToHashSet();
|
||||
var tasks = await _context.Tasks
|
||||
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
|
||||
.ToListAsync(ct);
|
||||
|
||||
for (int i = 0; i < orderedTaskIds.Count; i++)
|
||||
{
|
||||
var task = tasks.FirstOrDefault(t => t.Id == orderedTaskIds[i]);
|
||||
if (task is not null) task.SortOrder = i;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Kept for backwards-compatibility with callers using the old name.
|
||||
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
|
||||
=> GetByListIdAsync(listId, ct);
|
||||
|
||||
public async Task<List<TaskEntity>> GetByCreatorAsync(string createdBy, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Tasks
|
||||
.AsNoTracking()
|
||||
.Where(t => t.CreatedBy == createdBy)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Status transitions
|
||||
|
||||
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
|
||||
internal async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
@@ -59,7 +100,7 @@ public sealed class TaskRepository
|
||||
.SetProperty(t => t.StartedAt, startedAt), ct);
|
||||
}
|
||||
|
||||
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
|
||||
internal async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
@@ -69,7 +110,7 @@ public sealed class TaskRepository
|
||||
.SetProperty(t => t.Result, result), ct);
|
||||
}
|
||||
|
||||
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
|
||||
internal async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
@@ -86,7 +127,7 @@ public sealed class TaskRepository
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.LogPath, logPath), ct);
|
||||
}
|
||||
|
||||
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
|
||||
internal async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
|
||||
{
|
||||
var resultText = "[stale] " + reason;
|
||||
var now = DateTime.UtcNow;
|
||||
@@ -103,7 +144,7 @@ public sealed class TaskRepository
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Manual)
|
||||
.SetProperty(t => t.Status, TaskStatus.Idle)
|
||||
.SetProperty(t => t.StartedAt, (DateTime?)null)
|
||||
.SetProperty(t => t.FinishedAt, (DateTime?)null)
|
||||
.SetProperty(t => t.Result, (string?)null), ct);
|
||||
@@ -111,88 +152,342 @@ public sealed class TaskRepository
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tags
|
||||
#region Agent settings
|
||||
|
||||
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
|
||||
public async Task UpdateAgentSettingsAsync(
|
||||
string taskId,
|
||||
string? model,
|
||||
string? systemPrompt,
|
||||
string? agentPath,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||
if (task is null) return;
|
||||
var tag = await _context.Tags.FindAsync([tagId], ct);
|
||||
if (tag is not null && !task.Tags.Any(t => t.Id == tagId))
|
||||
{
|
||||
task.Tags.Add(tag);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
|
||||
{
|
||||
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||
if (task is null) return;
|
||||
var tag = task.Tags.FirstOrDefault(t => t.Id == tagId);
|
||||
if (tag is not null)
|
||||
{
|
||||
task.Tags.Remove(tag);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Tasks
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.SelectMany(t => t.Tags)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
var taskTags = _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.SelectMany(t => t.Tags);
|
||||
var listTags = _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.SelectMany(t => t.List.Tags);
|
||||
return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Model, model)
|
||||
.SetProperty(t => t.SystemPrompt, systemPrompt)
|
||||
.SetProperty(t => t.AgentPath, agentPath), ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Queue selection
|
||||
#region Planning
|
||||
|
||||
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
||||
public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default)
|
||||
{
|
||||
// Atomic queue claim: UPDATE + RETURNING in one statement prevents TOCTOU races.
|
||||
// Uses raw SQL because EF cannot express UPDATE...RETURNING.
|
||||
// Includes both task-level and list-level "agent" tag so lists tagged "agent"
|
||||
// automatically enqueue all their tasks without per-task tagging.
|
||||
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — use the same format for comparison.
|
||||
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||
var result = await _context.Tasks.FromSqlRaw("""
|
||||
UPDATE tasks SET status = 'running'
|
||||
WHERE id = (
|
||||
SELECT t.id FROM tasks t
|
||||
WHERE t.status = 'queued'
|
||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM task_tags tt
|
||||
JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE tt.task_id = t.id AND tg.name = 'agent'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM list_tags lt
|
||||
JOIN tags tg ON tg.id = lt.tag_id
|
||||
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
|
||||
)
|
||||
)
|
||||
ORDER BY t.created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING *
|
||||
""", nowStr).ToListAsync(ct);
|
||||
return await _context.Tasks
|
||||
.AsNoTracking()
|
||||
.Where(t => t.ParentTaskId == parentId)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
return result.FirstOrDefault();
|
||||
public async Task<TaskEntity> CreateChildAsync(
|
||||
string parentId,
|
||||
string title,
|
||||
string? description,
|
||||
string? commitType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which
|
||||
// bypasses the change tracker; a tracked Find would return stale data.
|
||||
var parent = await _context.Tasks.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||
if (parent is null)
|
||||
throw new InvalidOperationException($"Parent task {parentId} not found.");
|
||||
if (parent.PlanningPhase == PlanningPhase.None)
|
||||
throw new InvalidOperationException(
|
||||
$"Parent task {parentId} is not in a planning phase; cannot attach children.");
|
||||
|
||||
var maxSort = await _context.Tasks
|
||||
.Where(t => t.ListId == parent.ListId)
|
||||
.Select(t => (int?)t.SortOrder)
|
||||
.MaxAsync(ct);
|
||||
|
||||
var child = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = parent.ListId,
|
||||
Title = title,
|
||||
Description = description,
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType,
|
||||
ParentTaskId = parentId,
|
||||
SortOrder = (maxSort ?? -1) + 1,
|
||||
};
|
||||
_context.Tasks.Add(child);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return child;
|
||||
}
|
||||
|
||||
public async Task UpdateChildAsync(
|
||||
string taskId,
|
||||
string? title,
|
||||
string? description,
|
||||
string? commitType,
|
||||
TaskStatus? status,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
if (title is not null) task.Title = title;
|
||||
if (description is not null) task.Description = description;
|
||||
if (commitType is not null) task.CommitType = commitType;
|
||||
if (status.HasValue) task.Status = status.Value;
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task UpdatePlanningTaskAsync(
|
||||
string taskId,
|
||||
string? title,
|
||||
string? description,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct)
|
||||
?? throw new InvalidOperationException("Planning task not found.");
|
||||
if (title is not null) entity.Title = title;
|
||||
if (description is not null) entity.Description = description;
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Title, entity.Title)
|
||||
.SetProperty(t => t.Description, entity.Description), ct);
|
||||
}
|
||||
|
||||
public async Task<TaskEntity?> SetPlanningStartedAsync(
|
||||
string taskId,
|
||||
string sessionToken,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var affected = await _context.Tasks
|
||||
.Where(t => t.Id == taskId
|
||||
&& t.Status == TaskStatus.Idle
|
||||
&& t.PlanningPhase == PlanningPhase.None)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.PlanningPhase, PlanningPhase.Active)
|
||||
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
|
||||
|
||||
if (affected == 0) return null;
|
||||
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||
}
|
||||
|
||||
public async Task SetPlanningSessionTokenAsync(
|
||||
string taskId,
|
||||
string sessionToken,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
|
||||
}
|
||||
|
||||
public async Task UpdatePlanningSessionIdAsync(
|
||||
string parentId,
|
||||
string sessionId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == parentId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.PlanningSessionId, sessionId), ct);
|
||||
}
|
||||
|
||||
public async Task<TaskEntity?> FindByPlanningTokenAsync(
|
||||
string token,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
return await _context.Tasks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
|
||||
}
|
||||
|
||||
public async Task<DiscardPlanningOutcome> DiscardPlanningAsync(
|
||||
string parentId,
|
||||
bool dequeueQueuedChildren,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
||||
|
||||
var parent = await _context.Tasks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||
if (parent is null || parent.PlanningPhase != PlanningPhase.Active)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return new DiscardPlanningOutcome(DiscardPlanningResult.NotInPlanning, 0, 0);
|
||||
}
|
||||
|
||||
var children = await _context.Tasks
|
||||
.Where(t => t.ParentTaskId == parentId)
|
||||
.Select(t => new { t.Id, t.Status })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var runningCount = children.Count(c => c.Status == TaskStatus.Running);
|
||||
if (runningCount > 0)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return new DiscardPlanningOutcome(DiscardPlanningResult.BlockedByRunningChildren, 0, runningCount);
|
||||
}
|
||||
|
||||
var queuedIds = children.Where(c => c.Status == TaskStatus.Queued).Select(c => c.Id).ToList();
|
||||
if (queuedIds.Count > 0)
|
||||
{
|
||||
if (!dequeueQueuedChildren)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return new DiscardPlanningOutcome(DiscardPlanningResult.BlockedByQueuedChildren, queuedIds.Count, 0);
|
||||
}
|
||||
|
||||
await _context.Tasks
|
||||
.Where(t => queuedIds.Contains(t.Id))
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Idle)
|
||||
.SetProperty(t => t.BlockedByTaskId, (string?)null), ct);
|
||||
}
|
||||
|
||||
// Terminal children (Done/Failed/Cancelled) stay attached to the parent even
|
||||
// though its PlanningPhase will be reset to None. The lineage is preserved as
|
||||
// historical context; the UI nests them under their parent regardless of phase.
|
||||
|
||||
// Idle children created during this planning session are dropped.
|
||||
await _context.Tasks
|
||||
.Where(t => t.ParentTaskId == parentId
|
||||
&& t.Status == TaskStatus.Idle
|
||||
&& t.PlanningPhase == PlanningPhase.None)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == parentId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Idle)
|
||||
.SetProperty(t => t.PlanningPhase, PlanningPhase.None)
|
||||
.SetProperty(t => t.PlanningSessionId, (string?)null)
|
||||
.SetProperty(t => t.PlanningSessionToken, (string?)null)
|
||||
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
return new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, queuedIds.Count, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dequeues child tasks whose parent is missing or no longer in a planning phase:
|
||||
/// sets <c>Status</c> from <c>Queued</c> to <c>Idle</c> and clears
|
||||
/// <c>BlockedByTaskId</c>. <c>ParentTaskId</c> stays intact — the child remains
|
||||
/// part of its (former) planning chain for historical context. Returns the
|
||||
/// number of rows dequeued. Idempotent.
|
||||
/// </summary>
|
||||
internal async Task<int> DequeueOrphanedChildrenAsync(CancellationToken ct = default)
|
||||
{
|
||||
var orphanIds = await _context.Tasks
|
||||
.Where(t => t.ParentTaskId != null && t.Status == TaskStatus.Queued)
|
||||
.Where(t => !_context.Tasks.Any(p =>
|
||||
p.Id == t.ParentTaskId && p.PlanningPhase != PlanningPhase.None))
|
||||
.Select(t => t.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (orphanIds.Count == 0) return 0;
|
||||
|
||||
return await _context.Tasks
|
||||
.Where(t => orphanIds.Contains(t.Id))
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Idle)
|
||||
.SetProperty(t => t.BlockedByTaskId, (string?)null), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores a planning-session lineage that lost its <c>parent_task_id</c> links.
|
||||
/// Given a candidate parent task and a single unambiguous orphan chain in the
|
||||
/// same list (linked via <c>BlockedByTaskId</c>), re-attaches the chain members
|
||||
/// to the parent, marks the parent as <c>Finalized</c>, and dequeues queued
|
||||
/// chain members. No-op if conditions are not met. Returns the number of
|
||||
/// re-attached children (0 if skipped).
|
||||
/// </summary>
|
||||
internal async Task<int> RestorePlanningLineageAsync(string parentId, CancellationToken ct = default)
|
||||
{
|
||||
var parent = await _context.Tasks.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||
if (parent is null) return 0;
|
||||
if (parent.PlanningPhase != PlanningPhase.None) return 0;
|
||||
if (parent.Status is TaskStatus.Done or TaskStatus.Failed or TaskStatus.Cancelled) return 0;
|
||||
|
||||
// Candidates: unattached tasks in the same list, excluding the parent itself.
|
||||
var candidates = await _context.Tasks.AsNoTracking()
|
||||
.Where(t => t.ListId == parent.ListId && t.ParentTaskId == null && t.Id != parent.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// A chain is a maximal linear sequence linked via BlockedByTaskId. Find heads
|
||||
// (BlockedByTaskId == null) that have at least one successor.
|
||||
var bySource = candidates
|
||||
.Where(c => c.BlockedByTaskId != null)
|
||||
.ToLookup(c => c.BlockedByTaskId!);
|
||||
|
||||
var heads = candidates
|
||||
.Where(c => c.BlockedByTaskId == null && bySource[c.Id].Any())
|
||||
.ToList();
|
||||
|
||||
// Bail unless exactly one chain anchors a successor — anything else is
|
||||
// ambiguous and we refuse to guess.
|
||||
if (heads.Count != 1) return 0;
|
||||
|
||||
var chain = new List<TaskEntity> { heads[0] };
|
||||
var current = heads[0];
|
||||
while (true)
|
||||
{
|
||||
var next = bySource[current.Id].FirstOrDefault();
|
||||
if (next is null) break;
|
||||
chain.Add(next);
|
||||
current = next;
|
||||
}
|
||||
|
||||
var chainIds = chain.Select(c => c.Id).ToList();
|
||||
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == parentId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized), ct);
|
||||
|
||||
await _context.Tasks
|
||||
.Where(t => chainIds.Contains(t.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ParentTaskId, (string?)parentId), ct);
|
||||
|
||||
// Dequeue queued chain members; blocked_by stays intact so chain order is
|
||||
// preserved for manual re-queueing.
|
||||
await _context.Tasks
|
||||
.Where(t => chainIds.Contains(t.Id) && t.Status == TaskStatus.Queued)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Idle), ct);
|
||||
|
||||
return chainIds.Count;
|
||||
}
|
||||
|
||||
public async Task TryCompleteParentAsync(
|
||||
string parentId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||
if (parent is null || parent.PlanningPhase != PlanningPhase.Finalized) return;
|
||||
|
||||
var children = await _context.Tasks
|
||||
.Where(t => t.ParentTaskId == parentId)
|
||||
.Select(t => t.Status)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (children.Count == 0) return;
|
||||
|
||||
bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed);
|
||||
if (!allTerminal) return;
|
||||
|
||||
bool anyFailed = children.Any(s => s == TaskStatus.Failed);
|
||||
var finalStatus = anyFailed ? TaskStatus.Failed : TaskStatus.Done;
|
||||
var finishedAt = DateTime.UtcNow;
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == parentId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, finalStatus)
|
||||
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Windows;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Releases;
|
||||
using ClaudeDo.Installer.Pages.InstallPage;
|
||||
using ClaudeDo.Installer.Pages.PathsPage;
|
||||
using ClaudeDo.Installer.Pages.ServicePage;
|
||||
@@ -21,6 +22,104 @@ public partial class App : Application
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
// --- Self-update pre-flight ---
|
||||
// Resolve current exe path. Assembly.Location may point to a .dll for apphost-based
|
||||
// .NET apps; swap to the .exe companion when that happens.
|
||||
var currentExePath = Assembly.GetEntryAssembly()!.Location;
|
||||
if (currentExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
currentExePath = System.IO.Path.ChangeExtension(currentExePath, ".exe");
|
||||
}
|
||||
|
||||
// Arg form: --replace-self "<old-path>"
|
||||
var replaceSelfIndex = Array.FindIndex(e.Args, a => a.Equals("--replace-self", StringComparison.OrdinalIgnoreCase));
|
||||
if (replaceSelfIndex >= 0 && replaceSelfIndex + 1 < e.Args.Length)
|
||||
{
|
||||
var oldPath = e.Args[replaceSelfIndex + 1];
|
||||
var relaunched = await SelfUpdater.HandleReplaceSelfAsync(
|
||||
oldPath: oldPath,
|
||||
currentExePath: currentExePath,
|
||||
launchProcess: path =>
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
});
|
||||
if (relaunched)
|
||||
{
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
// Replacement failed — fall through to normal wizard from the temp location.
|
||||
}
|
||||
else
|
||||
{
|
||||
// Normal launch: check for a newer installer.
|
||||
using var selfUpdateHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
var selfUpdateReleases = new ReleaseClient(selfUpdateHttp);
|
||||
var currentVersion = GetInstallerVersion();
|
||||
|
||||
var decision = await SelfUpdater.DecideUpdateAsync(selfUpdateReleases, currentVersion, CancellationToken.None);
|
||||
if (decision.Kind == SelfUpdateDecisionKind.UpdateAvailable)
|
||||
{
|
||||
var prompt = new SelfUpdatePromptWindow(currentVersion, decision.LatestVersion!);
|
||||
DarkTitleBar.Apply(prompt);
|
||||
var ok = prompt.ShowDialog() == true;
|
||||
if (!ok)
|
||||
{
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
if (prompt.Choice == SelfUpdateChoice.Update)
|
||||
{
|
||||
prompt.ShowProgress("Downloading...");
|
||||
var tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ClaudeDo.Installer.Update");
|
||||
var verifiedPath = await SelfUpdater.DownloadAndVerifyAsync(
|
||||
selfUpdateReleases,
|
||||
decision.InstallerAsset!,
|
||||
decision.ChecksumsAsset!,
|
||||
tempDir,
|
||||
new Progress<long>(_ => { }),
|
||||
CancellationToken.None);
|
||||
|
||||
if (verifiedPath is null)
|
||||
{
|
||||
MessageBox.Show(prompt,
|
||||
"Update download or verification failed. Continuing with current installer.",
|
||||
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo(verifiedPath)
|
||||
{
|
||||
UseShellExecute = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--replace-self");
|
||||
psi.ArgumentList.Add(currentExePath);
|
||||
System.Diagnostics.Process.Start(psi);
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(prompt,
|
||||
"Failed to launch updated installer: " + ex.Message + "\nContinuing with current installer.",
|
||||
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
// SelfUpdateChoice.Continue — fall through to normal wizard.
|
||||
}
|
||||
// No-update or check failed — fall through to normal wizard.
|
||||
}
|
||||
|
||||
// --- Existing wizard start-up unchanged below this line ---
|
||||
|
||||
_services = BuildServices();
|
||||
|
||||
var context = _services.GetRequiredService<InstallContext>();
|
||||
@@ -50,6 +149,7 @@ public partial class App : Application
|
||||
context.Mode = state.Mode;
|
||||
context.InstalledVersion = state.Existing?.Version;
|
||||
context.LatestVersion = state.LatestVersion;
|
||||
context.LatestTagUnparseable = state.LatestTagUnparseable;
|
||||
if (state.Existing is not null)
|
||||
context.InstallDirectory = state.Existing.InstallDir;
|
||||
|
||||
@@ -103,24 +203,26 @@ public partial class App : Application
|
||||
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
|
||||
|
||||
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
|
||||
// Double-registered as both IInstallStep and concrete type so Task 15's Update pipeline
|
||||
// Double-registered as both IInstallStep and concrete type so the Update pipeline
|
||||
// can pull them out individually via GetRequiredService<T>().
|
||||
sc.AddSingleton<DownloadAndExtractStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
|
||||
sc.AddSingleton<IInstallStep, WriteConfigStep>();
|
||||
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
||||
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartServiceStep>());
|
||||
sc.AddSingleton<RegisterAutostartStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
|
||||
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
||||
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
|
||||
sc.AddSingleton<WriteUninstallRegistryStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteUninstallRegistryStep>());
|
||||
sc.AddSingleton<WriteInstallManifestStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
|
||||
// Start the worker last in the fresh pipeline (binaries + task must exist first).
|
||||
sc.AddSingleton<StartWorkerStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartWorkerStep>());
|
||||
|
||||
// Stop — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
|
||||
// Pulled by Update flow + Repair/Uninstall.
|
||||
sc.AddSingleton<StopServiceStep>();
|
||||
// StartServiceStep is also registered as IInstallStep above (fresh-install pipeline).
|
||||
sc.AddSingleton<StartServiceStep>();
|
||||
sc.AddSingleton<StopWorkerStep>();
|
||||
|
||||
// Runners
|
||||
sc.AddSingleton<UninstallRunner>();
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
||||
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,6 +5,23 @@ using ClaudeDo.Data;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
internal static class JsonConfigFile
|
||||
{
|
||||
public static T LoadOrDefault<T>(string fileName, JsonSerializerOptions readOpts) where T : new()
|
||||
{
|
||||
var path = Path.Combine(Paths.AppDataRoot(), fileName);
|
||||
if (!File.Exists(path)) return new();
|
||||
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), readOpts) ?? new();
|
||||
}
|
||||
|
||||
public static void Save<T>(string fileName, T value, JsonSerializerOptions writeOpts)
|
||||
{
|
||||
var dir = Paths.AppDataRoot();
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(Path.Combine(dir, fileName), JsonSerializer.Serialize(value, writeOpts));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape.
|
||||
/// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs.
|
||||
@@ -47,21 +64,9 @@ public sealed class InstallerWorkerConfig
|
||||
};
|
||||
|
||||
public static InstallerWorkerConfig Load()
|
||||
{
|
||||
var path = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||
if (!File.Exists(path)) return new();
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<InstallerWorkerConfig>(json, ReadOpts) ?? new();
|
||||
}
|
||||
=> JsonConfigFile.LoadOrDefault<InstallerWorkerConfig>("worker.config.json", ReadOpts);
|
||||
|
||||
public void Save()
|
||||
{
|
||||
var dir = Paths.AppDataRoot();
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "worker.config.json");
|
||||
var json = JsonSerializer.Serialize(this, WriteOpts);
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
public void Save() => JsonConfigFile.Save("worker.config.json", this, WriteOpts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -85,25 +90,9 @@ public sealed class InstallerAppSettings
|
||||
|
||||
public static InstallerAppSettings Load()
|
||||
{
|
||||
var path = Path.Combine(Paths.AppDataRoot(), "ui.config.json");
|
||||
if (!File.Exists(path)) return new();
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<InstallerAppSettings>(json, ReadOpts) ?? new();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new();
|
||||
}
|
||||
try { return JsonConfigFile.LoadOrDefault<InstallerAppSettings>("ui.config.json", ReadOpts); }
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
var dir = Paths.AppDataRoot();
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "ui.config.json");
|
||||
var json = JsonSerializer.Serialize(this, WriteOpts);
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
public void Save() => JsonConfigFile.Save("ui.config.json", this, WriteOpts);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ public sealed class InstallContext
|
||||
public string? InstallerVersion { get; set; } // from this installer's assembly
|
||||
public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
|
||||
public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
|
||||
public bool LatestTagUnparseable { get; set; } // true if latest tag isn't a System.Version
|
||||
|
||||
// PathsPage
|
||||
public string DbPath { get; set; } = "~/.todo-app/todo.db";
|
||||
@@ -22,7 +23,6 @@ public sealed class InstallContext
|
||||
public int SignalRPort { get; set; } = 47_821;
|
||||
public int QueueBackstopIntervalMs { get; set; } = 30_000;
|
||||
public string ClaudeBin { get; set; } = "claude";
|
||||
public string ServiceAccount { get; set; } = "CurrentUser";
|
||||
public bool AutoStart { get; set; } = true;
|
||||
public int RestartDelayMs { get; set; } = 5000;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user