Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef7645c6b1 | ||
|
|
92ce7a4a77 | ||
|
|
4dc4fe27e3 | ||
|
|
0ee30bee03 | ||
|
|
b9e0721875 | ||
|
|
92eb654f9b | ||
|
|
724814f770 | ||
|
|
b5464fc533 | ||
|
|
44cdad386c | ||
|
|
58f8b11dbb | ||
|
|
e653677487 | ||
|
|
994e94c2af | ||
|
|
db447f36da | ||
|
|
df2fcd8def | ||
|
|
17ef99bc9b | ||
|
|
c4425d6499 | ||
|
|
be6ccb2c17 | ||
|
|
57d433276e | ||
|
|
785ebe55e4 | ||
|
|
e1807fd53b | ||
|
|
149e2adadb | ||
|
|
d569313598 | ||
|
|
0a3c25840f | ||
|
|
7d6cb2bd3e | ||
|
|
7c8a9dd61b | ||
|
|
3fbbd7ab93 | ||
|
|
24f999facd | ||
|
|
3a648b7d77 | ||
|
|
fde9615b34 | ||
|
|
c93a20f20c | ||
|
|
1466d0fbab | ||
|
|
6c8de4aef3 | ||
|
|
7f9f0ca128 | ||
|
|
edd2774d86 | ||
|
|
62fc5aaa5a | ||
|
|
023e136c53 | ||
|
|
4877802bcd | ||
|
|
40eb979924 | ||
|
|
e3bacc3143 | ||
|
|
cc823ec4f6 | ||
|
|
ec10b06848 | ||
|
|
81cf94187f | ||
|
|
538b4ede09 | ||
|
|
95918414a0 | ||
|
|
9349386675 | ||
|
|
083e1f3948 | ||
|
|
ecbc73495c | ||
|
|
327ae2b69c | ||
|
|
5ba0c09d8f | ||
|
|
78d4e1a46b | ||
|
|
c7d64e9c9b | ||
|
|
7517f2a9b3 | ||
|
|
f4f7c81059 | ||
|
|
2a3ab5504a | ||
|
|
962f68c92b | ||
|
|
d12a888683 | ||
|
|
5c3ec4810e | ||
|
|
49222a92a4 | ||
|
|
109a35c505 | ||
|
|
34b17537fc | ||
|
|
2aaaa23912 | ||
|
|
624ec7a668 | ||
|
|
3e9ea3ad58 | ||
|
|
ef285b21fd | ||
|
|
2612831a5e | ||
|
|
5e27486b18 | ||
|
|
04044bd115 | ||
|
|
798d100636 | ||
|
|
e8f7e3a47a | ||
|
|
8a7275a75f | ||
|
|
f4dd67d595 | ||
|
|
0226c98076 | ||
|
|
b9b3053051 | ||
|
|
671c886c75 | ||
|
|
ffff1ee183 | ||
|
|
da6a70aec1 | ||
|
|
85d0f9dcec | ||
|
|
ad2acddc9a | ||
|
|
efd7cc9b0a | ||
|
|
75a6e0efc4 | ||
|
|
9efc5c90f4 | ||
|
|
6ca8cac79f | ||
|
|
9a1fa3dbaf | ||
|
|
9b4d3431b0 | ||
|
|
a1ba3b6ebb | ||
|
|
9b041ba791 | ||
|
|
39fc594b98 | ||
|
|
b353ed6cfd | ||
|
|
416e47ecef | ||
|
|
e8b5e97a9b | ||
|
|
d19ef5403f | ||
|
|
ded068c564 | ||
|
|
9556e0e848 | ||
|
|
9d39a8fc69 | ||
|
|
b536b6fb66 | ||
|
|
1b80bb0a2d | ||
|
|
4394623bb0 | ||
|
|
f0b0582517 | ||
|
|
07de897147 | ||
|
|
21012283a9 | ||
|
|
8241bf8d41 | ||
|
|
255705d8bf | ||
|
|
3dfd75fba8 | ||
|
|
26c03a5a5f | ||
|
|
3211bfc0f9 | ||
|
|
79ce7afe46 | ||
|
|
0ad93f48b5 | ||
|
|
fee69998f8 | ||
|
|
451afc80f8 | ||
|
|
4b2667062f | ||
|
|
305eb6ee8b | ||
|
|
15bef2a2e3 | ||
|
|
d7ebafd556 | ||
|
|
14e4c086e2 | ||
|
|
0f2d202b01 | ||
|
|
2a4633fdd8 | ||
|
|
3d668da65c | ||
|
|
f18e03354a | ||
|
|
a1986dd485 | ||
|
|
110364aa6c | ||
|
|
0b86ccd74b | ||
|
|
d6891b8bd4 | ||
|
|
377409e633 | ||
|
|
941c8b98cc | ||
|
|
2452e39345 | ||
|
|
816f247d90 | ||
|
|
ad58129194 | ||
|
|
85c7e650c9 | ||
|
|
c412a84fdf | ||
|
|
d8194ad57e | ||
|
|
d9a4627a1f | ||
|
|
2b06ab0ab4 | ||
|
|
bb62740ac8 | ||
|
|
25922a2768 | ||
|
|
3feb08d9d9 | ||
|
|
9ab48d7094 | ||
|
|
0513265c49 | ||
|
|
d28c63d2df | ||
|
|
5f740c05d8 | ||
|
|
1245e75902 | ||
|
|
d91ad2d635 | ||
|
|
17235a6cde | ||
|
|
f33838d028 | ||
|
|
1cfd96c15f | ||
|
|
7c3c061428 | ||
|
|
b4c58087d2 | ||
|
|
4626481359 | ||
|
|
dea2b7db8b | ||
|
|
54cdaf89d5 | ||
|
|
dbaefe92c6 | ||
|
|
62b245aaea | ||
|
|
4e5057d3f6 | ||
|
|
1bf08eca27 | ||
|
|
eb88dc130c | ||
|
|
140ae2fda1 | ||
|
|
865e12c0de | ||
|
|
914fa5aa9f | ||
|
|
711374e858 | ||
|
|
faf6104645 | ||
|
|
3eea2b7c96 | ||
|
|
afe7218b7c | ||
|
|
fd1e38fb7f | ||
|
|
e7fa373a74 | ||
|
|
7c9ff18ced | ||
|
|
84034e8395 | ||
|
|
8e1732a3a0 | ||
|
|
786eb2877f | ||
|
|
bdda98eccd | ||
|
|
9c292e5080 | ||
|
|
1fe72a1fe2 | ||
|
|
140b8e1551 | ||
|
|
9effddeb2c | ||
|
|
30e87e698e | ||
|
|
d8a043fae7 | ||
|
|
10342bc562 | ||
|
|
917301d61c | ||
|
|
c7f8280106 | ||
|
|
bec26b2232 | ||
|
|
05aec8ebfa | ||
|
|
946d26cc4b | ||
|
|
3b629c218f | ||
|
|
9eb54a0d2f | ||
|
|
1c94fbdb14 | ||
|
|
7f4dc8b973 | ||
|
|
f6ecfc995f | ||
|
|
f63be285a2 | ||
|
|
e2fad88f37 | ||
|
|
fbcffce79c | ||
|
|
5f6e7480f2 | ||
|
|
4e2798b400 | ||
|
|
b1bd91292f | ||
|
|
283310a3fd | ||
|
|
15a3e65508 | ||
|
|
5a21d673c1 | ||
|
|
42da840066 | ||
|
|
aa7a49f634 | ||
|
|
7b6a8f0852 | ||
|
|
d00899b655 | ||
|
|
66907d24c9 | ||
|
|
38defee3d8 | ||
|
|
d80a57836c | ||
|
|
178fd25b55 | ||
|
|
df84fc3f2c | ||
|
|
ea16da2756 | ||
|
|
f86b78593e | ||
|
|
19340fd9de | ||
|
|
0a119f1450 | ||
|
|
167d2fec6a | ||
|
|
4022bd7197 | ||
|
|
c4f74a7aea | ||
|
|
08a4f97a78 | ||
|
|
eb0ddb56d3 | ||
|
|
60eb671e8f | ||
|
|
134b9fb598 | ||
|
|
9301bbc81a | ||
|
|
637886f33a | ||
|
|
3cb4802f38 | ||
|
|
8716dd8e3a | ||
|
|
d8ff8cc110 | ||
|
|
f7e946e472 | ||
|
|
6a0c0f59a5 | ||
|
|
5be4b5c5fb | ||
|
|
3f9f047955 | ||
|
|
5231ad6b86 | ||
|
|
d598a539bc | ||
|
|
1fb2e34f85 | ||
|
|
b3e099ca01 | ||
|
|
0993eb0e75 | ||
|
|
bae8921201 | ||
|
|
23a93ce0bb | ||
|
|
29a294b7f3 | ||
|
|
ca4377e641 | ||
|
|
d5eec75bea | ||
|
|
18479c023e | ||
|
|
869dd25a23 | ||
|
|
c4d1acc75b | ||
|
|
378a92c156 | ||
|
|
983c177c9a | ||
|
|
3e4e4a03f7 | ||
|
|
92767c646e | ||
|
|
e779e13654 | ||
|
|
4847c5c0a4 | ||
|
|
43fb506e87 | ||
|
|
b75a7b1b5a | ||
|
|
824f785fd0 | ||
|
|
0d1475cb7a | ||
|
|
cfe23cdd23 | ||
|
|
cee051bb6d | ||
|
|
23c3065f20 | ||
|
|
80a2de6c74 | ||
|
|
17c7ff517a | ||
|
|
8b347de131 | ||
|
|
619bc0c38d | ||
|
|
96da9fbae5 | ||
|
|
1ac9ced0bd | ||
|
|
8cbe1adb32 | ||
|
|
23ff3916cc | ||
|
|
360ff77e18 | ||
|
|
e272053e72 | ||
|
|
74ca2e0dcd | ||
|
|
0cba9f9640 | ||
|
|
c6534165b2 | ||
|
|
290b4a602a | ||
|
|
fe73f45b74 | ||
|
|
d2a08d2cda | ||
|
|
8194dadb6a | ||
|
|
fb1d799b82 | ||
|
|
12fdb55a8e | ||
|
|
eee5c99e2f | ||
|
|
37df51475e | ||
|
|
53b666dfbd | ||
|
|
cd5501e6a6 | ||
|
|
b5417f6b09 | ||
|
|
7e739afafb | ||
|
|
e9e4ad8fbc | ||
|
|
d4af345ac3 | ||
|
|
ddeded988a | ||
|
|
c27a179d2b | ||
|
|
1448794748 | ||
|
|
51ef488d2f | ||
|
|
49046310ef | ||
|
|
f8f20bf6ed | ||
|
|
f21c65be18 | ||
|
|
c300f8c313 | ||
|
|
d6e0953293 | ||
|
|
a8b86e25e6 | ||
|
|
1abb429f12 | ||
|
|
803c04d9e0 | ||
|
|
12732d6dc9 | ||
|
|
b3a2daf40d | ||
|
|
8f49ebb248 | ||
|
|
f56cc617c3 | ||
|
|
ca8326c4c5 | ||
|
|
f5d165baae | ||
|
|
61a40d549b | ||
|
|
5723b81992 | ||
|
|
7f1a14ab80 | ||
|
|
33bdff8a6e | ||
|
|
b5cf19b19a | ||
|
|
9f19a714f7 | ||
|
|
b672c9aaf3 | ||
|
|
384e058812 | ||
|
|
01e0c1d794 | ||
|
|
00a065bf7f | ||
|
|
763732a9b3 | ||
|
|
a41b8de47a | ||
|
|
18b777a712 | ||
|
|
7f173daecb | ||
|
|
e71c0ed24f | ||
|
|
d450153183 | ||
|
|
72687e9b30 | ||
|
|
d52243ccd1 | ||
|
|
8cafad370e | ||
|
|
d8a973d0e1 | ||
|
|
0b623b8e4a | ||
|
|
5edb433755 | ||
|
|
c8f82ed3c2 | ||
|
|
1aa06077a8 | ||
|
|
cb20877620 | ||
|
|
dcbf67c63b | ||
|
|
02b11c727c | ||
|
|
74afc46909 | ||
|
|
ef3fba1690 | ||
|
|
ef2f5c51e4 | ||
|
|
3060cb0242 | ||
|
|
3596053512 | ||
|
|
4bf4a27036 | ||
|
|
de4ad5dcf3 | ||
|
|
2dfc4559b1 | ||
|
|
dd3b03b9e4 | ||
|
|
f4416ee1c3 | ||
|
|
42bb79e2b7 | ||
|
|
561028e67b | ||
|
|
07a9d07cf6 | ||
|
|
19435b2d48 | ||
|
|
e22a3267fe | ||
|
|
9c5872eb27 | ||
|
|
8819a56496 | ||
|
|
6c65158be8 | ||
|
|
096519b978 | ||
|
|
266e6d191b | ||
|
|
cb4c396a53 | ||
|
|
6e3f90d289 | ||
|
|
de01579e84 | ||
|
|
0d8999dc20 | ||
|
|
3202c76674 | ||
|
|
43f8f7f7d8 | ||
|
|
f1cf29b58d | ||
|
|
98b0d58e03 | ||
|
|
b817c87656 | ||
|
|
2a6781f80f | ||
|
|
4098f7f341 | ||
|
|
82390047d2 | ||
|
|
75ad7b1735 | ||
|
|
e523ed85eb | ||
|
|
0460d7bea5 | ||
|
|
66a7b2377f | ||
|
|
eca6813cdb | ||
|
|
22830d3ea8 | ||
|
|
3573548348 | ||
|
|
0867bc8296 | ||
|
|
1603be0c78 | ||
|
|
71a3765c07 | ||
|
|
b840655163 | ||
|
|
ac9bae9546 | ||
|
|
99c6bf4478 | ||
|
|
3e848710b8 | ||
|
|
a2c339cd87 | ||
|
|
c71026d125 | ||
|
|
ce50f9fcce | ||
|
|
c323953f8c | ||
|
|
9f95942dd1 | ||
|
|
299867d8df | ||
|
|
8f7e2898fe | ||
|
|
9f37b1e21e | ||
|
|
c5a4e350e9 | ||
|
|
e547921fdd | ||
|
|
f1316dfd0e | ||
|
|
cc7355eaa4 | ||
|
|
22a1ba7f30 | ||
|
|
a3f407b0e5 | ||
|
|
469e68bbc8 | ||
|
|
176b9855bf | ||
|
|
5d34f95fe0 | ||
|
|
0e130177fc | ||
|
|
5363570fb4 | ||
|
|
f60becaf06 | ||
|
|
519bfbe6b3 | ||
|
|
06e3acd5ac | ||
|
|
f3052dc5fc | ||
|
|
9d133e227b | ||
|
|
7542bc2058 | ||
|
|
ef86a8c29b | ||
|
|
da23b6cd3a | ||
|
|
c10f564265 | ||
|
|
8036de1019 | ||
|
|
7873e60095 | ||
|
|
6f4b5d5544 | ||
|
|
f25c7599bd | ||
|
|
6fdf04d6a0 | ||
|
|
ee0d1257dd | ||
|
|
204b089000 | ||
|
|
da4ab0ca5e | ||
|
|
c035720b37 | ||
|
|
4522ac906b | ||
|
|
2455eacb1f | ||
|
|
d8b86e33a3 | ||
|
|
49b9f1ffde | ||
|
|
4d52845130 | ||
|
|
9a117a5429 | ||
|
|
202e8dea49 | ||
|
|
1e547dea18 | ||
|
|
56ebc2803f | ||
|
|
cf7f0da400 | ||
|
|
ac1e9b06de | ||
|
|
79bfc79d33 | ||
|
|
1b3c6bdbb4 | ||
|
|
bd1e3db1d9 | ||
|
|
edc9f77357 | ||
|
|
883dbc6af7 | ||
|
|
9bdf99d95f | ||
|
|
c8f468f270 | ||
|
|
84fd2c11a0 | ||
|
|
30b49d1071 | ||
|
|
ad7d74820a | ||
|
|
75aa42b877 | ||
|
|
925b72ae83 | ||
|
|
cd683ba227 | ||
|
|
d0ab382973 | ||
|
|
3e3041c1c7 | ||
|
|
92cee125cc | ||
|
|
bba3c55e1c | ||
|
|
26f5936d14 | ||
|
|
b72a7888e4 | ||
|
|
beae2d639d | ||
|
|
ac137f7c1c | ||
|
|
97e38fb480 | ||
|
|
b63c78c234 | ||
|
|
37ce673a57 | ||
|
|
b9741ef38b | ||
|
|
0a0d7e8551 | ||
|
|
2dfa9956c5 | ||
|
|
773811d060 | ||
|
|
3756b81817 | ||
|
|
72a86fc173 | ||
|
|
cc46019622 | ||
|
|
71ac48162a | ||
|
|
bcf5e2f51f | ||
|
|
fb055ce740 | ||
|
|
9e7f37b5cc | ||
|
|
39fa83a0a0 | ||
|
|
15ed624d4a | ||
|
|
52e3980cd1 | ||
|
|
53d897aff4 | ||
|
|
7d743f17c6 | ||
|
|
26758b6e8a | ||
|
|
914095dc99 | ||
|
|
4d82079cac | ||
|
|
3a40e39fc8 | ||
|
|
2e73d3333d | ||
|
|
c764b2bf6e | ||
|
|
f7d1b37343 | ||
|
|
fab17720cc | ||
|
|
9470c5b10b | ||
|
|
c45f892591 | ||
|
|
a8670ee23a | ||
|
|
7676ecf0d4 | ||
|
|
fa83d7f441 | ||
|
|
e48475d6cd | ||
|
|
46f42a4d93 | ||
|
|
46ac3fc930 | ||
|
|
5e0859fbb8 | ||
|
|
2d00160283 | ||
|
|
20b3a29d08 | ||
|
|
fd7f8ac78f | ||
|
|
0bb809445e | ||
|
|
3c66d65160 | ||
|
|
ffe0fb9820 | ||
|
|
00ef11ac33 | ||
|
|
312b411654 | ||
|
|
364a037cb3 | ||
|
|
2fbf054a57 | ||
|
|
350a89f364 | ||
|
|
086c6f6c45 | ||
|
|
070f5de1b1 | ||
|
|
f529a5ff22 | ||
|
|
6a85d82fcf | ||
|
|
35ad1715d3 | ||
|
|
3c40bb5ea3 | ||
|
|
d95d55e6b8 | ||
|
|
d22b50e171 | ||
|
|
a83a0c41e8 | ||
|
|
9efde2bf88 | ||
|
|
8dc8b8ba8e | ||
|
|
baeea9c2a7 | ||
|
|
a935bf9664 | ||
|
|
2d55f88a41 | ||
|
|
a8d8a8bd65 | ||
|
|
0bc3d2a6c4 | ||
|
|
b886d58c07 | ||
|
|
a8943a9f7a | ||
|
|
eccd06e182 | ||
|
|
731c291d61 | ||
|
|
c8b5ed3912 | ||
|
|
9bf44da13b | ||
|
|
b748c1569e | ||
|
|
74fc39f1a6 | ||
|
|
ccd2ee2cc7 | ||
|
|
5b89e3d03f | ||
|
|
e106b00b16 | ||
|
|
d7558ef451 | ||
|
|
4aa4353d11 | ||
|
|
50d84f12c9 | ||
|
|
e2271b5a50 | ||
|
|
bec87b3d6f | ||
|
|
4cb7ad8dfa | ||
|
|
992fbf0763 | ||
|
|
1d7b86dbef | ||
|
|
036586e736 | ||
|
|
d9e5d2600b | ||
|
|
10d86b4bd6 | ||
|
|
f72cfae7d9 | ||
|
|
e5a2ed250d | ||
|
|
536d819328 | ||
|
|
869cf72abe | ||
|
|
f1715a34fa | ||
|
|
26998f05ff | ||
|
|
7db8f213d8 | ||
|
|
37738e3c8f | ||
|
|
81fd186fb2 | ||
|
|
3127930454 | ||
|
|
bed4255a5e | ||
|
|
dff06d9e35 | ||
|
|
0efad7a004 | ||
|
|
eaf27e8b3a | ||
|
|
13c3393e3a | ||
|
|
4704a28e5d | ||
|
|
1cb5171fba | ||
|
|
4684a0af76 | ||
|
|
6c27ffbdca | ||
|
|
21f1cf2a85 | ||
|
|
c88ed9d5eb | ||
|
|
9c1f20f2d9 | ||
|
|
e8d018dd54 | ||
|
|
1ca32a6bdd | ||
|
|
b86677d554 | ||
|
|
3e072fae66 |
@@ -0,0 +1,101 @@
|
|||||||
|
name: Dependency Audit
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 6 * * 1' # Mondays 06:00 UTC
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DOTNET_ROOT: /home/mika/.dotnet
|
||||||
|
GITEA_API: https://git.kuns.dev/api/v1
|
||||||
|
REPO: releases/ClaudeDo
|
||||||
|
ISSUE_TITLE: 'Dependency audit: vulnerable packages detected'
|
||||||
|
steps:
|
||||||
|
- name: Checkout main
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git clone --depth 1 --branch main \
|
||||||
|
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" src
|
||||||
|
|
||||||
|
- name: Scan for vulnerable / outdated packages
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="$DOTNET_ROOT:$PATH"
|
||||||
|
cd src
|
||||||
|
|
||||||
|
: > audit.log
|
||||||
|
: > vuln.md
|
||||||
|
found=0
|
||||||
|
|
||||||
|
# .slnx tooling needs .NET 9; iterate per-project to stay on .NET 8.
|
||||||
|
while IFS= read -r proj; do
|
||||||
|
echo "==== $proj ====" | tee -a audit.log
|
||||||
|
dotnet restore "$proj" >/dev/null
|
||||||
|
|
||||||
|
vuln="$(dotnet list "$proj" package --vulnerable --include-transitive 2>&1)"
|
||||||
|
echo "$vuln" | tee -a audit.log
|
||||||
|
if echo "$vuln" | grep -qi "has the following vulnerable"; then
|
||||||
|
found=1
|
||||||
|
{
|
||||||
|
printf '#### `%s`\n\n```\n' "$proj"
|
||||||
|
echo "$vuln"
|
||||||
|
printf '```\n\n'
|
||||||
|
} >> vuln.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Outdated is informational only — never fails the run.
|
||||||
|
dotnet list "$proj" package --outdated 2>&1 | tee -a audit.log || true
|
||||||
|
echo "" | tee -a audit.log
|
||||||
|
done < <(find . -name '*.csproj' | sort)
|
||||||
|
|
||||||
|
if [ "$found" -ne 0 ]; then
|
||||||
|
echo "::error::Vulnerable packages detected — see log above." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "No vulnerable packages found."
|
||||||
|
|
||||||
|
- name: Report vulnerabilities to a Gitea issue
|
||||||
|
if: failure()
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd src
|
||||||
|
|
||||||
|
if [ -s vuln.md ]; then
|
||||||
|
DETAILS="$(cat vuln.md)"
|
||||||
|
else
|
||||||
|
DETAILS="The audit job failed before producing findings — check the run log."
|
||||||
|
fi
|
||||||
|
BODY="$(printf 'Automated weekly dependency audit found vulnerable packages.\n\n%s\n\n[View workflow run](%s)' \
|
||||||
|
"$DETAILS" "$RUN_URL")"
|
||||||
|
|
||||||
|
# Reuse an existing open issue if one is already tracking this.
|
||||||
|
EXISTING="$(curl -sS \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_API}/repos/${REPO}/issues?state=open&type=issues&limit=50" \
|
||||||
|
| jq -r --arg t "$ISSUE_TITLE" '.[] | select(.title==$t) | .number' | head -n1)"
|
||||||
|
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
echo "Commenting on existing issue #$EXISTING"
|
||||||
|
jq -n --arg body "$BODY" '{body:$body}' \
|
||||||
|
| curl -sS --fail-with-body -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @- \
|
||||||
|
"${GITEA_API}/repos/${REPO}/issues/${EXISTING}/comments" >/dev/null
|
||||||
|
else
|
||||||
|
echo "Creating new issue"
|
||||||
|
jq -n --arg title "$ISSUE_TITLE" --arg body "$BODY" '{title:$title, body:$body}' \
|
||||||
|
| curl -sS --fail-with-body -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @- \
|
||||||
|
"${GITEA_API}/repos/${REPO}/issues" >/dev/null
|
||||||
|
fi
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
name: Changelog
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changelog:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
REPO: releases/ClaudeDo
|
||||||
|
steps:
|
||||||
|
- name: Checkout main (full history)
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git clone "https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" src
|
||||||
|
cd src
|
||||||
|
git fetch --tags --force
|
||||||
|
git checkout main
|
||||||
|
|
||||||
|
- name: Regenerate CHANGELOG.md
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd src
|
||||||
|
|
||||||
|
emit_group() {
|
||||||
|
# $1 range, $2 conventional-type, $3 heading
|
||||||
|
local range="$1" type="$2" title="$3" lines
|
||||||
|
lines="$(git log "$range" --no-merges --pretty=format:'%s|%h' \
|
||||||
|
| grep -E "^${type}(\([^)]*\))?(!)?: " || true)"
|
||||||
|
[ -z "$lines" ] && return 0
|
||||||
|
printf '### %s\n\n' "$title"
|
||||||
|
while IFS='|' read -r subject hash; do
|
||||||
|
printf -- '- %s (%s)\n' "${subject#*: }" "$hash"
|
||||||
|
done <<< "$lines"
|
||||||
|
printf '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
emit_section() {
|
||||||
|
# $1 range, $2 tag, $3 date
|
||||||
|
printf '## %s — %s\n\n' "$2" "$3"
|
||||||
|
emit_group "$1" feat "Features"
|
||||||
|
emit_group "$1" fix "Fixes"
|
||||||
|
emit_group "$1" perf "Performance"
|
||||||
|
emit_group "$1" refactor "Refactoring"
|
||||||
|
emit_group "$1" docs "Documentation"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tags ascending by semver so we can pair each with its predecessor.
|
||||||
|
mapfile -t TAGS < <(git tag --sort=v:refname | grep -E '^v' || true)
|
||||||
|
|
||||||
|
{
|
||||||
|
printf '# Changelog\n\n'
|
||||||
|
for ((i=${#TAGS[@]}-1; i>=0; i--)); do
|
||||||
|
TAG="${TAGS[$i]}"
|
||||||
|
DATE="$(git log -1 --format=%ad --date=short "$TAG")"
|
||||||
|
if (( i > 0 )); then
|
||||||
|
RANGE="${TAGS[$((i-1))]}..${TAG}"
|
||||||
|
else
|
||||||
|
RANGE="$TAG"
|
||||||
|
fi
|
||||||
|
emit_section "$RANGE" "$TAG" "$DATE"
|
||||||
|
done
|
||||||
|
} > CHANGELOG.md
|
||||||
|
|
||||||
|
cat CHANGELOG.md
|
||||||
|
|
||||||
|
- name: Commit and push if changed
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd src
|
||||||
|
if git diff --quiet -- CHANGELOG.md; then
|
||||||
|
echo "CHANGELOG.md unchanged; nothing to commit."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config user.name "ClaudeDo CI"
|
||||||
|
git config user.email "ci@kuns.dev"
|
||||||
|
git add CHANGELOG.md
|
||||||
|
git commit -m "docs(changelog): update for ${GITHUB_REF_NAME}"
|
||||||
|
git push origin main
|
||||||
@@ -5,6 +5,10 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -38,11 +42,52 @@ jobs:
|
|||||||
TAG: ${{ steps.ver.outputs.tag }}
|
TAG: ${{ steps.ver.outputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
git clone --depth 1 --branch "$TAG" \
|
# Full clone (with tags) so release notes can diff against the previous tag.
|
||||||
|
git clone --branch "$TAG" \
|
||||||
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
|
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
|
||||||
"$WORK/src"
|
"$WORK/src"
|
||||||
git -C "$WORK/src" log -1 --oneline
|
git -C "$WORK/src" log -1 --oneline
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
env:
|
||||||
|
WORK: ${{ steps.ws.outputs.dir }}
|
||||||
|
TAG: ${{ steps.ver.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$WORK/src"
|
||||||
|
|
||||||
|
PREV="$(git tag --sort=v:refname | grep -E '^v' \
|
||||||
|
| awk -v t="$TAG" '$0==t{print prev} {prev=$0}')"
|
||||||
|
if [ -n "$PREV" ]; then
|
||||||
|
RANGE="${PREV}..${TAG}"
|
||||||
|
else
|
||||||
|
RANGE="$TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
emit_group() {
|
||||||
|
# $1 conventional-type, $2 heading
|
||||||
|
local lines
|
||||||
|
lines="$(git log "$RANGE" --no-merges --pretty=format:'%s|%h' \
|
||||||
|
| grep -E "^${1}(\([^)]*\))?(!)?: " || true)"
|
||||||
|
[ -z "$lines" ] && return 0
|
||||||
|
printf '### %s\n\n' "$2"
|
||||||
|
while IFS='|' read -r subject hash; do
|
||||||
|
printf -- '- %s (%s)\n' "${subject#*: }" "$hash"
|
||||||
|
done <<< "$lines"
|
||||||
|
printf '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
emit_group feat "Features"
|
||||||
|
emit_group fix "Fixes"
|
||||||
|
emit_group perf "Performance"
|
||||||
|
emit_group refactor "Refactoring"
|
||||||
|
emit_group docs "Documentation"
|
||||||
|
} > RELEASE_NOTES.md
|
||||||
|
|
||||||
|
echo "--- release notes ---"
|
||||||
|
cat RELEASE_NOTES.md
|
||||||
|
|
||||||
- name: Publish ClaudeDo.App (win-x64, self-contained)
|
- name: Publish ClaudeDo.App (win-x64, self-contained)
|
||||||
env:
|
env:
|
||||||
WORK: ${{ steps.ws.outputs.dir }}
|
WORK: ${{ steps.ws.outputs.dir }}
|
||||||
@@ -100,18 +145,19 @@ jobs:
|
|||||||
ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
|
ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
|
||||||
( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker )
|
( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker )
|
||||||
|
|
||||||
# 2) Installer single-file exe (renamed)
|
# 2) Installer single-file exe — STABLE name (no version) so the download URL
|
||||||
|
# (…/releases/latest/download/ClaudeDo.Installer.exe) stays permanent.
|
||||||
INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1)
|
INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1)
|
||||||
if [ -z "$INSTALLER_EXE" ]; then
|
if [ -z "$INSTALLER_EXE" ]; then
|
||||||
echo "::error::No .exe produced by installer publish" >&2
|
echo "::error::No .exe produced by installer publish" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe"
|
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer.exe"
|
||||||
|
|
||||||
# 3) Checksums (sha256, relative filenames)
|
# 3) Checksums (sha256, relative filenames)
|
||||||
( cd assets && sha256sum \
|
( cd assets && sha256sum \
|
||||||
"ClaudeDo-${VERSION}-win-x64.zip" \
|
"ClaudeDo-${VERSION}-win-x64.zip" \
|
||||||
"ClaudeDo.Installer-${VERSION}.exe" \
|
"ClaudeDo.Installer.exe" \
|
||||||
> checksums.txt )
|
> checksums.txt )
|
||||||
|
|
||||||
echo "--- assets ---"
|
echo "--- assets ---"
|
||||||
@@ -128,7 +174,8 @@ jobs:
|
|||||||
BODY=$(jq -n \
|
BODY=$(jq -n \
|
||||||
--arg tag "$TAG" \
|
--arg tag "$TAG" \
|
||||||
--arg name "$TAG" \
|
--arg name "$TAG" \
|
||||||
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
|
--rawfile body "$WORK/src/RELEASE_NOTES.md" \
|
||||||
|
'{tag_name:$tag, name:$name, body:$body, draft:true, prerelease:false, target_commitish:"main"}')
|
||||||
RESP=$(curl -sS -X POST \
|
RESP=$(curl -sS -X POST \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
@@ -154,7 +201,7 @@ jobs:
|
|||||||
cd "$WORK/src/assets"
|
cd "$WORK/src/assets"
|
||||||
for f in \
|
for f in \
|
||||||
"ClaudeDo-${VERSION}-win-x64.zip" \
|
"ClaudeDo-${VERSION}-win-x64.zip" \
|
||||||
"ClaudeDo.Installer-${VERSION}.exe" \
|
"ClaudeDo.Installer.exe" \
|
||||||
"checksums.txt"
|
"checksums.txt"
|
||||||
do
|
do
|
||||||
echo "Uploading: $f"
|
echo "Uploading: $f"
|
||||||
@@ -166,6 +213,32 @@ jobs:
|
|||||||
done
|
done
|
||||||
echo "All assets uploaded."
|
echo "All assets uploaded."
|
||||||
|
|
||||||
|
- name: Publish release
|
||||||
|
env:
|
||||||
|
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
curl -sS --fail-with-body -X PATCH \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"draft":false}' \
|
||||||
|
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}" \
|
||||||
|
> /dev/null
|
||||||
|
echo "Release ${RELEASE_ID} published."
|
||||||
|
|
||||||
|
- name: Delete draft release on failure
|
||||||
|
if: failure() && steps.release.outputs.release_id != ''
|
||||||
|
env:
|
||||||
|
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
curl -sS -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}" \
|
||||||
|
> /dev/null || true
|
||||||
|
echo "Cleaned up draft release ${RELEASE_ID}."
|
||||||
|
|
||||||
- name: Cleanup workspace
|
- name: Cleanup workspace
|
||||||
if: always()
|
if: always()
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Local dev worktrees (created by using-git-worktrees skill)
|
# Local dev worktrees (created by using-git-worktrees skill)
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
|
# Brainstorming visual companion artifacts
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
# .NET build output
|
# .NET build output
|
||||||
bin/
|
bin/
|
||||||
|
|||||||
+1222
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,11 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
|
|||||||
- **ClaudeDo.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm)
|
- **ClaudeDo.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm)
|
||||||
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
|
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
|
||||||
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
|
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
|
||||||
- **ClaudeDo.Worker.Tests** — xUnit integration tests with real SQLite and real git
|
- **ClaudeDo.Localization** — `locales/en.json` + `locales/de.json` and the lookup service
|
||||||
|
- **ClaudeDo.Installer** — WPF (`UseWPF`) setup app; install/update/uninstall step pipeline
|
||||||
|
- **tests/** — six xUnit projects (Worker, Data, Ui, Localization, Installer, Releases); Worker.Tests run real SQLite and real git
|
||||||
|
|
||||||
|
Each project has its own `CLAUDE.md` — those are the living per-project docs.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
@@ -35,7 +39,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
|
|||||||
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
|
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
|
||||||
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
|
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
|
||||||
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
|
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
|
||||||
- Task status flow: Idle | Queued -> Running -> Done | Failed | Cancelled
|
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A task that spawns/has children passes through WaitingForChildren first, then surfaces for review once every child is terminal — this is the single parent model for both planning and improvement parents (planning/improvement *children* themselves go straight to Done, only the parent is reviewed). From review you can approve, reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel. Approve is the single review+merge action: a childless task merges its own worktree then Done (conflicts keep it in WaitingForReview); a task with children drives the unit merge (parent worktree if any + each Done child in order, with conflict continue/abort). Tasks with no active worktree (sandbox run) approve straight to Done. In the detail pane, **Approve & Merge is gated behind opening the diff**: when there is something to inspect (worktree diff / merged range / children combined diff) the button stays disabled until the diff or combined-diff viewer has been opened once, and re-locks per run (any state change resets it); tasks with nothing to inspect are never gated. The row-level quick-approve in the task list is an intentional bypass.
|
||||||
- Worktree state flow: Active -> Merged | Discarded | Kept
|
- Worktree state flow: Active -> Merged | Discarded | Kept
|
||||||
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
|
- 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)
|
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
|
||||||
@@ -44,18 +48,40 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
|
|||||||
- Views use compiled bindings (`x:DataType`)
|
- Views use compiled bindings (`x:DataType`)
|
||||||
- ViewModels use `[ObservableProperty]` and `[RelayCommand]` source generators
|
- ViewModels use `[ObservableProperty]` and `[RelayCommand]` source generators
|
||||||
|
|
||||||
|
## Working style (autonomous)
|
||||||
|
|
||||||
|
For any non-trivial feature, bug, or change, run this loop without hand-holding:
|
||||||
|
|
||||||
|
1. **Brainstorm first** (superpowers:brainstorming) — ask clarifying questions one at a time, propose 2–3 options with a recommendation, present a short design, get approval before building.
|
||||||
|
2. **Write it down** — a spec in `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` and a step-by-step plan in `docs/superpowers/plans/` (superpowers:writing-plans). Commit the docs.
|
||||||
|
3. **Implement on main** with superpowers:subagent-driven-development — one subagent per task, TDD, build + test, commit per task with Conventional Commits. Once the plan is approved, do NOT pause for re-approval between tasks; only stop for genuine decisions or blockers.
|
||||||
|
4. **Trust but verify** — read each subagent's diff and run the build/tests yourself before marking a task done.
|
||||||
|
5. **Bugs** → superpowers:systematic-debugging (find the root cause before any fix).
|
||||||
|
6. **Never claim UI works without running it** — explicitly flag visual-verification gaps for the user to check.
|
||||||
|
|
||||||
|
Commit freely (per task + the spec/plan docs). Never push without asking.
|
||||||
|
|
||||||
## Building & Testing
|
## Building & Testing
|
||||||
|
|
||||||
`dotnet build ClaudeDo.slnx` requires .NET 9; on .NET 8 build individual projects instead.
|
`dotnet build ClaudeDo.slnx` requires .NET 9; on .NET 8 build individual projects with `-c Release` (a running Worker locks the `Debug` output).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj # pulls in Ui + Data
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release # pulls in Ui + Data
|
||||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
dotnet test tests/ClaudeDo.Worker.Tests # also: Data.Tests, Ui.Tests, Installer.Tests, Releases.Tests
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release # also: Data.Tests, Ui.Tests, Localization.Tests, Installer.Tests, Releases.Tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Gotchas
|
||||||
|
- **Subagents:** use the `sonnet` model; stage files explicitly by path — never `git add -A` (parallel sessions often leave unrelated WIP in the tree).
|
||||||
|
- **Icons:** `PathIcon` *fills* its geometry. Line-art/stroke icons must be authored as filled geometry, or rendered with a stroked `Path` — otherwise they render invisible.
|
||||||
|
- **Localization:** `locales/en.json` and `locales/de.json` keys must stay in parity (Localization.Tests enforces it).
|
||||||
|
- **Test fakes:** changing `IWorkerClient` / `WorkerHub` / ViewModel constructors breaks hand-rolled fakes in both test projects — update them.
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
- `docs/plan.md` — full architecture and design spec
|
- `docs/open.md` — open verification items and remaining code TODOs (the only doc kept current besides the CLAUDE.md files)
|
||||||
- `docs/open.md` — verification checklist and improvement backlog
|
- `docs/plan.md` — original design spec (historical; tag-queue/schema.sql parts are outdated)
|
||||||
- `docs/improvement-plan.md` — prioritized improvement items
|
- `docs/improvement-plan.md` — improvement snapshot from 2026-04-13 (historical)
|
||||||
|
- `docs/prompts-inventory.md`, `docs/mailbox-proposal.md` — reference material (mailbox integration is parked)
|
||||||
|
- `CHANGELOG.md` — Keep a Changelog format, maintained on release
|
||||||
|
- `docs/explore-notes/` — distilled maps of complex subsystems from deep exploration (detail too fine for a CLAUDE.md). **Before** deep-exploring a subsystem, check for a matching note first; **after** a deep explore, distill durable findings back and bump its "verified against" commit. Always verify against current code before trusting. See `docs/explore-notes/README.md`.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
|
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
|
||||||
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
|
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
|
||||||
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
|
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
|
||||||
|
<Project Path="src/ClaudeDo.Localization/ClaudeDo.Localization.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj" />
|
||||||
@@ -13,5 +14,6 @@
|
|||||||
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.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.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
|
||||||
<Project Path="tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj" />
|
||||||
|
<Project Path="tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Explore-notes
|
||||||
|
|
||||||
|
Distilled, reusable maps of complex subsystems, produced by deep code exploration.
|
||||||
|
The goal: stop re-exploring the same subsystem from scratch in every new session.
|
||||||
|
|
||||||
|
These sit **between** the CLAUDE.md files and the code:
|
||||||
|
|
||||||
|
- **CLAUDE.md** — high-level orientation, hand-maintained, always-loaded.
|
||||||
|
- **explore-notes** — deeper subsystem detail (flows, who-calls-whom, invariants) that is
|
||||||
|
too fine-grained for a CLAUDE.md but stable enough to be worth caching. Read on demand.
|
||||||
|
- **code** — the only source of truth.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Only stable structure.** Flows, responsibilities, entry points, invariants, relative
|
||||||
|
file paths. **No line numbers**, no exhaustive symbol dumps — those rot fastest.
|
||||||
|
- **Verify before trusting.** A note is a starting map, not authority. Always confirm
|
||||||
|
against current code before acting on it. Each note records the commit it was verified
|
||||||
|
against so you can diff for drift.
|
||||||
|
- **Not a substitute for CLAUDE.md.** If a fact belongs in orientation, put it there.
|
||||||
|
|
||||||
|
## Header every note must carry
|
||||||
|
|
||||||
|
```
|
||||||
|
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||||
|
> Last verified against commit `<short-hash>` (<date>).
|
||||||
|
> Drift check: `git log --oneline <short-hash>..HEAD -- <paths this note covers>`
|
||||||
|
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Before** deep-exploring a subsystem, check for a matching note here and read it first;
|
||||||
|
explore only to fill gaps or confirm.
|
||||||
|
2. **After** a deep explore, distill the durable findings into a new/updated note and bump
|
||||||
|
its "verified against" commit line.
|
||||||
|
3. If the drift check shows the covered paths changed a lot since the verified commit, treat
|
||||||
|
the note as suspect and re-verify the parts you rely on.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||||
|
> Last verified against commit `896d4b5` (2026-07-23).
|
||||||
|
> Drift check: `git log --oneline 896d4b5..HEAD -- src/ClaudeDo.Worker`
|
||||||
|
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||||
|
|
||||||
|
# Worker: Task Execution Pipeline
|
||||||
|
|
||||||
|
How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
|
||||||
|
(Queue, Runner, Lifecycle, State, Agents, Worktrees, Hub).
|
||||||
|
|
||||||
|
## End-to-End Flow (Queued → Terminal)
|
||||||
|
|
||||||
|
1. **Enqueue** — `ITaskStateService.EnqueueAsync()` (State/TaskStateService.cs)
|
||||||
|
- Idle → Queued, then wakes the dispatcher via `IQueueWaker.Wake()`.
|
||||||
|
|
||||||
|
2. **Dispatch** — `QueueService` loop (Queue/QueueService.cs)
|
||||||
|
- `BackgroundService`; waits for a wake signal or a backstop timer.
|
||||||
|
- Reads the max-parallel limit from settings; claims a free slot if under limit.
|
||||||
|
|
||||||
|
3. **Atomic Claim** — `IQueuePicker.ClaimNextAsync()` (Queue/QueuePicker.cs)
|
||||||
|
- Raw SQL `UPDATE ... RETURNING` in one transaction: picks an eligible Queued task
|
||||||
|
(unblocked, due or unscheduled; sorted by sort_order/created_at), sets status→Running
|
||||||
|
+ started_at, returns the row. Prevents two workers claiming the same task (TOCTOU).
|
||||||
|
|
||||||
|
4. **Slot Execution** — `QueueService.RunInSlotAsync()` (Queue/QueueService.cs)
|
||||||
|
- For review feedback: resume the prior session if one exists, else fold feedback into
|
||||||
|
the prompt. Calls `TaskRunner.RunAsync()` / `ContinueAsync()` with `alreadyClaimed=true`.
|
||||||
|
|
||||||
|
5. **Run Preparation** — `TaskRunner.RunAsync()` (Runner/TaskRunner.cs)
|
||||||
|
- Loads task, list config, subtasks, attachments from the DB.
|
||||||
|
- `PrepareRunDirectoryAsync()`: worktree (via WorktreeManager) if the list has a WorkingDir,
|
||||||
|
else sandbox. Generates a per-run MCP token, writes MCP config to disk.
|
||||||
|
- `StartRunningAsync()` (only if not pre-claimed): atomic Queued → Running. Broadcasts TaskStarted.
|
||||||
|
|
||||||
|
6. **Claude Execution** — `TaskRunner.RunOnceAsync()` (Runner/TaskRunner.cs)
|
||||||
|
- Creates a TaskRunEntity, points the task at the run's log path.
|
||||||
|
- Builds claude CLI args (ClaudeArgsBuilder), spawns the process via `IClaudeProcess.RunAsync()`
|
||||||
|
with prompt + working dir + streaming callback.
|
||||||
|
- Stream lines → NDJSON log + broadcast via TaskMessage. MCP tools (AskUser, SuggestImprovement)
|
||||||
|
are scoped by the per-run token.
|
||||||
|
|
||||||
|
7. **Result Handling** — `TaskRunner.HandleSuccess()` / `MarkFailed()` (Runner/TaskRunner.cs)
|
||||||
|
- Success (exit 0 + result markdown): if worktree, commit + broadcast WorktreeUpdated; then
|
||||||
|
transition to Done / WaitingForReview / WaitingForChildren (CompleteAsync / SubmitForReviewAsync
|
||||||
|
/ SubmitForChildrenAsync).
|
||||||
|
- Failure: if a session exists, auto-retry once via ContinueAsync; else MarkFailed → FailAsync.
|
||||||
|
- All terminal writes use `CancellationToken.None` so a task is never left Running.
|
||||||
|
|
||||||
|
8. **Terminal States** — `ITaskStateService` transitions (State/TaskStateService.cs)
|
||||||
|
- **Done** CompleteAsync (Running → Done) — top-level success.
|
||||||
|
- **WaitingForReview** SubmitForReviewAsync (Running → WaitingForReview) — review gate.
|
||||||
|
- **WaitingForChildren** SubmitForChildrenAsync (Running → WaitingForChildren) — blocks on children.
|
||||||
|
- **Failed** FailAsync (Running/Queued → Failed).
|
||||||
|
- **Cancelled** CancelAsync (Running/Queued/WaitingForReview/WaitingForChildren → Cancelled).
|
||||||
|
|
||||||
|
## Component Responsibilities
|
||||||
|
|
||||||
|
**Queue/**
|
||||||
|
- `QueueService` — main dispatch loop; slot limit; decides when to start tasks.
|
||||||
|
- `QueuePicker` — atomic Queued→Running claim via raw SQL.
|
||||||
|
- `QueueWaker` — semaphore for non-blocking, idempotent wake signals.
|
||||||
|
- `OverrideSlotService` — owns the RunNow / ContinueTask slot (bypasses the queue).
|
||||||
|
|
||||||
|
**Runner/**
|
||||||
|
- `TaskRunner` — orchestrates the run (prepare, execute, handle result).
|
||||||
|
- `WorktreeManager` — creates/manages git worktrees; self-heals stale branches.
|
||||||
|
- `ClaudeProcess` — spawns the claude CLI subprocess; manages streams/logs.
|
||||||
|
- `TaskRunMcpService` — runtime MCP tools (AskUser, SuggestImprovement).
|
||||||
|
- `TaskRunTokenRegistry` — per-run MCP identity for tool-access control.
|
||||||
|
- `InteractiveLaunchSpecService` — config for the task's claude run.
|
||||||
|
|
||||||
|
**State/**
|
||||||
|
- `TaskStateService` — all task status transitions; guards preconditions; signals queue/hub.
|
||||||
|
|
||||||
|
**Lifecycle/** (startup recovery)
|
||||||
|
- `StaleTaskRecovery` — tasks stuck Running after a crash/restart → Failed.
|
||||||
|
- `OrphanRecovery` — dequeues children whose parent is no longer planning (stays attached).
|
||||||
|
- `AttachmentOrphanRecovery` — cleans orphaned attachment files.
|
||||||
|
- `TaskResetService` — manual reset to Idle.
|
||||||
|
- `TaskMergeService` — conflict resolution for worktree merges.
|
||||||
|
|
||||||
|
**Hub/**
|
||||||
|
- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/RunCreated…).
|
||||||
|
- `WorkerHub` — SignalR hub + client methods.
|
||||||
|
|
||||||
|
**Agents/**
|
||||||
|
- `AgentFileService` — file I/O for custom agents.
|
||||||
|
- `DefaultAgentSeeder` — seeds built-in agents on startup.
|
||||||
|
|
||||||
|
**Worktrees/**
|
||||||
|
- `WorktreeMaintenanceService` — cleanup, state tracking, overview reporting.
|
||||||
|
|
||||||
|
## Entry Points & Call Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
Program.cs (DI setup)
|
||||||
|
├─ QueueService (BackgroundService) → ExecuteAsync loop
|
||||||
|
│ ├─ waits: IQueueWaker.WaitAsync() or timer
|
||||||
|
│ ├─ claims: IQueuePicker.ClaimNextAsync()
|
||||||
|
│ └─ runs: TaskRunner.RunAsync() / ContinueAsync()
|
||||||
|
├─ Hub clients → WorkerHub methods
|
||||||
|
│ ├─ Enqueue → ITaskStateService.EnqueueAsync() → Wake()
|
||||||
|
│ ├─ RunNow → OverrideSlotService.RunNow() → TaskRunner.RunAsync()
|
||||||
|
│ ├─ ContinueTask→ OverrideSlotService.ContinueTask()→ TaskRunner.ContinueAsync()
|
||||||
|
│ └─ CancelTask → QueueService.CancelTask()
|
||||||
|
├─ Lifecycle recovery (startup): StaleTaskRecovery / OrphanRecovery / AttachmentOrphanRecovery
|
||||||
|
└─ State transitions → HubBroadcaster.TaskUpdated()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invariants & Conventions
|
||||||
|
|
||||||
|
- **Atomic claiming** — QueuePicker's `UPDATE ... RETURNING` makes Queued→Running atomic.
|
||||||
|
- **Slot limit** — respects MaxParallelExecutions; a backstop timer wakes even if a Wake() is missed.
|
||||||
|
- **Pre-claimed tasks** — the dispatcher pre-claims via the picker; the override slot
|
||||||
|
(RunNow/ContinueTask) must call StartRunningAsync if a task is not pre-claimed.
|
||||||
|
- **Terminal writes** — use `CancellationToken.None`; a task is never left Running after crash/cancel.
|
||||||
|
- **Per-run MCP tokens** — each run gets a unique token scoping tool access; unregistered on end.
|
||||||
|
- **Auto-retry** — one automatic retry if a session exists and the first run failed.
|
||||||
|
- **Worktree self-heal** — on branch collision, remove phantom worktrees, prune, delete branch, retry add.
|
||||||
|
- **Review feedback** — stored on the task; consumed once a run reaches a terminal state; a re-queued
|
||||||
|
task resumes the session or folds feedback into the prompt.
|
||||||
|
- **Child tasks** — planning creates draft children; finalization requires no Queued children remain;
|
||||||
|
OrphanRecovery dequeues children if the parent is not planning.
|
||||||
|
- **Lifecycle recovery** runs at startup: stale-Running → Failed; orphaned children → dequeued but attached.
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Fix-Plan — Verifikations-Findings (Stand 2026-07-24)
|
||||||
|
|
||||||
|
Einstiegspunkt für eine **frische Fix-Session**. Sammelt die in der manuellen Verifikation
|
||||||
|
(§7–§9 + Kanten §1/§3/§4) gefundenen Probleme, gruppiert nach **Fixbarkeit**. Volltext je
|
||||||
|
Finding (mit Kontext/Wiederholschritten) steht in `docs/open.md`; hier steht der Fix-Blick:
|
||||||
|
Root-Cause, konkreter Ansatz, Loc/Test-Hinweise, und **welche Punkte vor der Umsetzung eine
|
||||||
|
Entscheidung brauchen**.
|
||||||
|
|
||||||
|
**Immer zuerst:** file:line-Angaben gegen den aktuellen Code prüfen (können minimal driften).
|
||||||
|
Build/Test-Regeln + Gotchas s. Projekt-`CLAUDE.md` (u.a. `.slnx` braucht .NET 9 → einzelne
|
||||||
|
`.csproj -c Release`; Localization.Tests erzwingt en/de-Parität; Subagents `sonnet`, Dateien
|
||||||
|
pfad-scoped stagen). Pro Fix ein Conventional Commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bearbeitungsstand (Session 2026-07-24, nicht gepusht)
|
||||||
|
|
||||||
|
**Erledigt & committed:**
|
||||||
|
- Gruppe A #1–5 + beide Optional-Nits (Icon.Plus gefüllt, Gear-PathIcon, Skills-Empty-State,
|
||||||
|
„Subtasks"-Terminologie, Conflict-Continue-Hinweis, Rename-Darstellung, Turns/Tokens-Reload).
|
||||||
|
- Gruppe B #7 (Resume-Fehler surfacen) + #8 (Attachment-Drop-Diagnose).
|
||||||
|
- Gruppe C #9 (AskUser-Banner in Detail-Insel via geteiltem `TaskMonitorViewModel`),
|
||||||
|
#11 (OUTCOME zeigt `summary` statt rohem JSON: Worker-Unwrap + UI-Sicherheitsnetz).
|
||||||
|
- Gruppe D #13 (Kind-Rows live-refresh bei Parent-Planning-Transitionen) + #14 (Idle-Chip auf
|
||||||
|
Planning-Parents ausgeblendet). *Visual-Verification für #13 (Finalize/Discard live) offen.*
|
||||||
|
|
||||||
|
**#6** war im aktuellen Code bereits abgedeckt (Worker wirft `HubException` bei `blocked`
|
||||||
|
→ UI-Dialog); zusätzlich als ClaudeDo-Task `f9809a93` erfasst. Nicht angefasst.
|
||||||
|
|
||||||
|
**Offen:**
|
||||||
|
- **#10 + #12** — bewusst gebündelt mit dem ConPTY-Planning-Task `5d627df8` (dort lässt sich
|
||||||
|
die Session-Id sauber greifen bzw. das MCP-Permission-Verhalten klären). Sofort-Schutz für
|
||||||
|
#10 (Resume ausgrauen wenn keine Id) wurde NICHT gebaut — bräuchte Worker-Plumbing, das der
|
||||||
|
ConPTY-Umbau ohnehin liefert; der #7-Fix verhindert bereits das stille Scheitern.
|
||||||
|
- **Gruppe E** — nur noch design-/feature-behaftete Punkte. Der scheinbare Quick-Win
|
||||||
|
„Dequeue-X auf blockierten Kettengliedern" wurde bewusst NICHT umgesetzt: einzelnes Dequeue
|
||||||
|
eines Kettenglieds hinterlässt hängende Nachfolger (deren `BlockedByTaskId` zeigt weiter auf
|
||||||
|
das nun idle Glied) → braucht Chain-Repair-Design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gruppe A — Mechanisch, sofort fixbar (keine Entscheidung nötig)
|
||||||
|
|
||||||
|
Ideale Kandidaten für den Start / parallele Subagents (disjunkte Dateien).
|
||||||
|
|
||||||
|
1. **„New session"-Button unsichtbar (Icon.Plus strich-only)**
|
||||||
|
`IslandStyles.axaml` (`Icon.Plus` = `M12 5v14M5 12h14`, reine Linie) wird in einem
|
||||||
|
`<PathIcon>` (MissionControlView.axaml, füllt Geometrie) unsichtbar gerendert.
|
||||||
|
**Fix:** `Icon.Plus` als gefüllte Geometrie authoren ODER als gestricheltes `Path`
|
||||||
|
rendern (vgl. `Path.plan-icon`). **Andere `Icon.Plus`-Verwendungen mitprüfen.**
|
||||||
|
|
||||||
|
2. **Agent-Settings-Gear weicht vom Listen-Gear ab**
|
||||||
|
`TaskHeaderBar.axaml:68` rendert `<TextBlock Text="⚙">`; überall sonst `Icon.Settings`
|
||||||
|
(PathIcon, IslandStyles.axaml:110). **Fix:** den `⚙`-TextBlock durch
|
||||||
|
`<PathIcon Data="{StaticResource Icon.Settings}" Width=".." Height=".."/>` ersetzen.
|
||||||
|
|
||||||
|
3. **Session-Skills-Tab ohne Empty-State**
|
||||||
|
Bei 0 Skills nur nackte Fläche. **Fix:** Empty-State-Text unter der Install-Zeile
|
||||||
|
(z.B. „No skills installed — paste a GitHub URL above"). **Loc:** neue Keys in en.json
|
||||||
|
**und** de.json (Parität!). Datei: `SessionSkillsSettingsTab*`.
|
||||||
|
|
||||||
|
4. **„Waiting for Improvements" für Planning-Parents (Terminologie)**
|
||||||
|
`en.json` `taskStatus.waitingForChildren`/`agentStatus.children`/`childOutcomesLabel`
|
||||||
|
sagen „Improvements". Seit unified-parent gilt `WaitingForChildren` auch für Planning.
|
||||||
|
**Fix:** auf neutrales „Waiting for Subtasks"/„Subtasks"/„SUBTASKS" umstellen — en **und**
|
||||||
|
de (Parität).
|
||||||
|
|
||||||
|
5. **Conflict-Resolver: Continue-Button klickbar trotz offener Konflikte**
|
||||||
|
Merge passiert korrekt erst nach Auflösung, aber der Button ist nicht disabled → früher
|
||||||
|
Klick = stummer No-op. **Fix:** `CanContinue`/`AllResolved` an `IsEnabled` binden (ggf.
|
||||||
|
Hinweis „N Konflikte in M Dateien offen"). Datei: `ConflictResolverView(.axaml)` +
|
||||||
|
`ConflictResolverViewModel`.
|
||||||
|
|
||||||
|
Optional-Nits (gleiche Gruppe, niedrige Prio):
|
||||||
|
- **Diff-Viewer Rename schwach dargestellt** (alt→neu-Pfad + „renamed"-Label statt „+0 −0").
|
||||||
|
- **Header-TurnsText `0/max`** bei terminalem Reload — `Turns` aus `task_runs.turnCount`
|
||||||
|
restaurieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gruppe B — Error-Surfacing (klare Richtung: kein stiller/leerer Fehlerpfad)
|
||||||
|
|
||||||
|
Leitlinie `feedback_ui_error_surfacing`: User-Action-Fehler in den Footer
|
||||||
|
(`FlashFooterError`) bzw. Dialog, nie leerer `catch`/stiller No-op.
|
||||||
|
|
||||||
|
6. **Approve & Merge schluckt „blocked" still**
|
||||||
|
`DetailsIslandViewModel.ApproveReviewAsync` reagiert nur auf `Status == "conflict"`; bei
|
||||||
|
`"blocked"` (z.B. dirty Ziel-Tree) passiert nichts. **Fix:** bei `blocked`/unerwartetem
|
||||||
|
Status `result.ErrorMessage` surfacen. *(Als ClaudeDo-Task `f9809a93` erfasst — koppelt
|
||||||
|
„Approve erzwingt Diff/Review vor Merge".)*
|
||||||
|
|
||||||
|
7. **„Resume planning session" verschluckt den Fehler (Teil-Fix hier, Rest → Gruppe C #10)**
|
||||||
|
`TasksIslandViewModel.ResumePlanningSessionAsync` (~Zeile 870) hüllt alles in `catch { }`.
|
||||||
|
**Sofort-Fix:** den Fehler surfacen statt schlucken. Der eigentliche Resume-Defekt braucht
|
||||||
|
eine Entscheidung → #10.
|
||||||
|
|
||||||
|
8. **Attachments: intermittenter erster-Drop-Fehler („An error occurred")**
|
||||||
|
Einmalig beobachtet (erster Drop der Session, nichts persistiert), nicht reproduzierbar.
|
||||||
|
**Fix (diagnostisch):** `AddFilesAsync`/`OnDrop` robustes Error-Logging geben (die genaue
|
||||||
|
Exception fehlt, weil `DropStatus` nur `{fileName}: {ex.Message}` zeigt) — damit der
|
||||||
|
nächste Fall auswertbar ist. Kandidaten-Ursachen: SQLite-Contention (UI schreibt `todo.db`
|
||||||
|
direkt, während der Worker sie hält) oder Drop-Stream-Pfad (`IStorageFile.OpenReadAsync`
|
||||||
|
im Code-Behind, außerhalb des `try`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gruppe C — Erst Entscheidung/Brainstorm, DANN umsetzen (nicht blind fixen)
|
||||||
|
|
||||||
|
9. **AskUser-Interaktion auch in der Detail-Insel** *(von Mika ausdrücklich gewünscht)*
|
||||||
|
Der `ask_user`-Banner + Inline-Antwort existiert nur in Mission Control
|
||||||
|
(`MonitorPaneView`); die Detail-Insel zeigt für den laufenden Task nichts.
|
||||||
|
**Entscheidung:** wie den Zustand teilen — `TaskMonitorViewModel` hält ihn bereits; für
|
||||||
|
die Detail-Insel replizieren, teilen, oder ein gemeinsames Banner-Control? Danach:
|
||||||
|
Banner + `AnswerDraft`/`SubmitAnswer` in `DetailsIslandView(Model)` einhängen.
|
||||||
|
|
||||||
|
10. **„Resume planning session" grundsätzlich kaputt (Session-Id nie erfasst)**
|
||||||
|
`PlanningSessionManager.ResumeAsync:238` wirft immer „No Claude session ID captured yet",
|
||||||
|
weil `TaskRepository.UpdatePlanningSessionIdAsync:322` **keinen Aufrufer** hat →
|
||||||
|
`planning_session_id` bleibt NULL. **Entscheidung:** (a) claude-Session-Id der
|
||||||
|
wt-Planning-Session erfassen + via `UpdatePlanningSessionIdAsync` persistieren (echtes
|
||||||
|
Resume) ODER (b) Resume entfernen/deaktivieren, wenn keine Id vorliegt. Hängt mit der
|
||||||
|
Design-Entscheidung „Planning über embedded ConPTY statt wt" zusammen (ClaudeDo-Task
|
||||||
|
`5d627df8`) — dort ließe sich die Session-Id sauber greifen.
|
||||||
|
|
||||||
|
11. **OUTCOME-Karte rendert rohes Structured-Output-JSON**
|
||||||
|
`TaskMonitorViewModel.ApplyOutcome` setzt bei Tasks ohne Roadblock `SessionOutcome`
|
||||||
|
= `task.Result` wörtlich; der Worker legt dort rohes `{"summary":…}` ab.
|
||||||
|
**Entscheidung:** (a) UI parst JSON-Result und zeigt `summary`, oder (b) Worker schreibt
|
||||||
|
`summary`/`resultMarkdown` statt JSON in `task.Result`.
|
||||||
|
|
||||||
|
12. **Planning-Session prompted nach MCP-Tool-Permission**
|
||||||
|
Trotz `--allowedTools "mcp__claudedo__*,…"` + `--permission-mode plan` prompted die
|
||||||
|
wt-Planning-Session beim ersten `create_child_task`. **Untersuchen/Entscheiden:** matcht
|
||||||
|
der `mcp__claudedo__*`-Glob in CLI 2.1.207 nicht (Syntax evtl. ganzer Server-Name), oder
|
||||||
|
gated Plan-Mode MCP-Writes generell? Gekoppelt an ConPTY-Planning-Task `5d627df8`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gruppe D — Erst Root-Cause pinnen (Investigation)
|
||||||
|
|
||||||
|
13. **Kind-Rows aktualisieren nach Parent-Planning-Transitionen nicht live**
|
||||||
|
Nach **Finalize** bleiben Kind-Badges „Draft" statt „Planned"; nach **Discard** bleiben
|
||||||
|
die (in der DB gelöschten) Kind-Rows sichtbar — bis Listen-Reload. Doppelt verifiziert §3.
|
||||||
|
**Untersuchen:** wie wird die Kinderliste/-gruppierung auf ein Parent-`TaskUpdated`
|
||||||
|
reagierend neu aufgelöst? Vermutlich fehlt ein Regroup/Refetch der Children beim
|
||||||
|
Parent-Broadcast (`TasksIslandViewModel` hierarchie-Regrouping). Fix danach: Children bei
|
||||||
|
Parent-Transition live neu auflösen.
|
||||||
|
|
||||||
|
14. **Planning-aktiver Parent zeigt weiter „Idle"**
|
||||||
|
Parent `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber der Row-Chip
|
||||||
|
zeigt „Idle"; `PlanningBadge` überschreibt das nicht sichtbar. **Untersuchen/Design:** ein
|
||||||
|
klarer „Planning/Draft aktiv"-Zustand, der den Idle-Chip überschreibt. (Verwandt mit #13 —
|
||||||
|
Row-Statusdarstellung.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gruppe E — UX-Nits / Feature-Wünsche (niedrige Prio, sammeln)
|
||||||
|
|
||||||
|
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar** — prominentere Datei-Liste
|
||||||
|
/ „x von y Dateien".
|
||||||
|
- **Blocked-by-Kette nicht visualisiert** — Reihenfolge/Abhängigkeit darstellen
|
||||||
|
(„wartet auf <Vorgänger>").
|
||||||
|
- **Dequeue-„X" fehlt auf blockierten Kettengliedern** — `CanRemoveFromQueue` erweitern
|
||||||
|
(`IsWaiting` einschließen).
|
||||||
|
- **„Open ConPTY session" erneut = Prompt wird neu gesendet** — Resume-Affordance / Re-Open-
|
||||||
|
Warnung (bewusst kein Session-Persist).
|
||||||
|
- **Conflict-Resolver: farbliches Hervorheben übernommener Zeilen im Result-Pane** (Feature).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht anfassen / Kontext
|
||||||
|
|
||||||
|
- **§1 DiffModal-Fehler-State** (`vm.diff.unavailable`) ist **defensiver, über die UI
|
||||||
|
unerreichbarer** Code — alle Aufrufer sind gegated (`CanDiffMergedRange` verlangt base+head
|
||||||
|
non-null; `ConfigureWorktree` nur mit existierendem Pfad). Kein Fix nötig.
|
||||||
|
- **`--permission-mode auto` + `haiku` denied Writes** — modellabhängiges Verhalten, keine
|
||||||
|
Regression; Default (sonnet) unbetroffen. Beobachten (Memory `auto_permission_haiku_footgun`).
|
||||||
|
- **§10 Daily Prep/Weekly** — Verifikation zurückgestellt bis zum geplanten Rework.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfohlene Reihenfolge
|
||||||
|
|
||||||
|
1. **Gruppe A** (mechanisch, schnell, teils parallel) → sofort sichtbare Wins.
|
||||||
|
2. **Gruppe B** (Error-Surfacing, klein & risikoarm).
|
||||||
|
3. **Gruppe C** — pro Punkt kurz brainstormen/entscheiden, dann umsetzen (#10 + #12 zusammen
|
||||||
|
mit der ConPTY-Planning-Entscheidung betrachten).
|
||||||
|
4. **Gruppe D** — Investigation, dann Fix (#13 zuerst — betrifft mehrere Planning-Flows).
|
||||||
|
5. **Gruppe E** — nach Bedarf.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# ClaudeDo — Improvement Plan (Session 2026-04-13)
|
# ClaudeDo — Improvement Plan (Session 2026-04-13)
|
||||||
|
|
||||||
|
> **Hinweis (2026-06-09):** Historischer Snapshot — bewusst nicht nachgepflegt. U.a. erledigt/überholt: IP-1 (Auto-Reconnect ist implementiert), `schema.sql` → EF-Core-Migrations, `StatusBarViewModel` existiert nicht mehr (Connection-State lebt in `IslandsShellViewModel`), Tags sind Junction-Tabellen statt JSON-Spalten. Offene Punkte stehen in `open.md`.
|
||||||
|
|
||||||
Erfasst während manuellem Walkthrough der App. Priorisiert nach Schmerz/Aufwand.
|
Erfasst während manuellem Walkthrough der App. Priorisiert nach Schmerz/Aufwand.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# Task Mailbox — Push Messages Into Running Sessions
|
# Task Mailbox — Push Messages Into Running Sessions
|
||||||
|
|
||||||
**Status:** proposal
|
**Status:** PARKED (2026-06-04) — not building this.
|
||||||
|
**Why parked:** The generic Claude-Mailbox plugin (the `mcp__mailbox__*` tools used in normal sessions) already covers the core need — cross-session messaging, inbox checks, a sender — at the harness level for any project. Integrating it directly into ClaudeDo (task/worktree-scoped inboxes, per-worktree CLAUDE.md + hook seeding, UI badges, `send_to_peer`) is a sizable build (migration + MCP tools + SignalR + UI + hooks) for marginal gain over the plugin. Revisit only if the generic plugin proves insufficient for the parallel-session workflow. The original proposal is kept below for reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**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.
|
**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
|
## Problem
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# ClaudeDo Online Inbox — API Contract & VPS build prompt
|
||||||
|
|
||||||
|
Status: handoff doc. The **server side** (API + minimal web client) is built and deployed
|
||||||
|
VPS-side by a separate Claude instance. This file is the source of truth for the contract
|
||||||
|
both ends implement against. The desktop client in this repo is built to match it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Concept
|
||||||
|
|
||||||
|
ClaudeDo is a local desktop app that runs tasks autonomously via the Claude CLI; it is
|
||||||
|
normally fully local (SQLite). The **Online Inbox** is an optional service that lets the
|
||||||
|
single owner view their task lists and add new tasks from a phone/browser. The desktop app
|
||||||
|
syncs against it.
|
||||||
|
|
||||||
|
**Governing rule:** the online store mirrors EXACTLY the desktop's `Idle` backlog — nothing
|
||||||
|
else. A task is present online only while it is `Idle` on the desktop. The moment the user
|
||||||
|
queues it locally, the desktop removes it from the online store. Running / WaitingForReview /
|
||||||
|
Done / Failed / Cancelled tasks never appear online.
|
||||||
|
|
||||||
|
Sync directions (each one-way per entity → no conflict resolution needed):
|
||||||
|
|
||||||
|
- **Lists**: desktop → online only. Desktop is the source of truth (full-replace catalog).
|
||||||
|
- **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the
|
||||||
|
desktop pulls down and then owns.
|
||||||
|
|
||||||
|
Single user today. Both the desktop and the web client authenticate as the **same Zitadel
|
||||||
|
user**.
|
||||||
|
|
||||||
|
**Multi-user readiness (`ownerId`).** Each resource is owned by a Zitadel subject (`sub`).
|
||||||
|
`RemoteList`, `RemoteTask`, and `MirrorTask` carry an optional `ownerId` field. The desktop
|
||||||
|
stamps its own `sub` (decoded from the access token) onto everything it pushes, and
|
||||||
|
defensively ignores any pulled task whose `ownerId` is set to a *different* user; an absent
|
||||||
|
`ownerId` is treated as unowned/legacy and still syncs. This keeps the contract ready for
|
||||||
|
multiple users **without enforcing isolation client-side** — the server remains the
|
||||||
|
authority that scopes every request by the token's `sub`. When the server goes multi-user it
|
||||||
|
should partition all rows by owner and ignore (or validate) the client-supplied `ownerId`.
|
||||||
|
|
||||||
|
**Access control (as of 2026-06-10).** Access is granted by assigning the **"user" project
|
||||||
|
role** in the Zitadel project "ClaudeDo" (id `376787351902355727`, issuer
|
||||||
|
`https://auth.kuns.dev`) — there is no app-side allowlist (the former `ALLOWED_USER_IDS`
|
||||||
|
env var is gone). The access token carries the role in the claim
|
||||||
|
`urn:zitadel:iam:org:project:roles` (or the project-scoped variant
|
||||||
|
`urn:zitadel:iam:org:project:376787351902355727:roles`), an object keyed by role key, e.g.
|
||||||
|
`{ "user": { "<orgId>": "<orgDomain>" } }`. The desktop OIDC client
|
||||||
|
(id `376787352137302287`) has `accessTokenRoleAssertion` enabled, so any token issued
|
||||||
|
after login/refresh includes the claim automatically — no extra scopes are needed.
|
||||||
|
Granting/revoking access is purely a Zitadel role grant, nothing app-side.
|
||||||
|
|
||||||
|
## 2. Idle backlog definition (desktop side)
|
||||||
|
|
||||||
|
The desktop mirrors only "real" backlog items, not planning internals:
|
||||||
|
|
||||||
|
- `Status == Idle`
|
||||||
|
- `ParentTaskId == null` (no planning/improvement children)
|
||||||
|
- `PlanningPhase == None`
|
||||||
|
- `BlockedByTaskId == null`
|
||||||
|
|
||||||
|
## 3. Data model (Postgres)
|
||||||
|
|
||||||
|
```
|
||||||
|
lists
|
||||||
|
id text primary key -- GUID supplied by the desktop; reuse verbatim
|
||||||
|
name text not null
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
|
||||||
|
tasks
|
||||||
|
id text primary key -- GUID; SHARED id space (see below)
|
||||||
|
list_id text not null references lists(id) on delete cascade
|
||||||
|
title text not null
|
||||||
|
description text
|
||||||
|
imported boolean not null default false -- false = web-created, awaiting desktop pull
|
||||||
|
-- true = desktop-owned (mirrored or handed off)
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shared GUID id space.** Web-created tasks get a server-generated GUID; the desktop imports
|
||||||
|
under that SAME id, so it never duplicates. Desktop-mirrored tasks arrive with their own GUID.
|
||||||
|
All task writes are idempotent upserts keyed on id.
|
||||||
|
|
||||||
|
**`imported` flag = ownership.**
|
||||||
|
- Web `POST /tasks` inserts `imported=false`.
|
||||||
|
- Desktop pulls `imported=false`, creates the task locally (reusing the id), then `POST
|
||||||
|
/tasks/{id}/imported` flips it to `true`. From then on the task belongs to the desktop
|
||||||
|
mirror.
|
||||||
|
- `PUT /tasks/mirror` only ever inserts/updates/deletes within the `imported=true` partition.
|
||||||
|
It never touches `imported=false` rows (those are pending handoff).
|
||||||
|
|
||||||
|
## 4. Endpoints
|
||||||
|
|
||||||
|
All endpoints require a valid Zitadel access token (`Authorization: Bearer <token>`) that
|
||||||
|
carries the **"user" project role** (see §1). Missing/invalid/expired token, or a valid
|
||||||
|
token without the role → `401`. No anonymous access (imported tasks can trigger code
|
||||||
|
execution on the user's machine). The desktop client treats a `401` as: force a
|
||||||
|
refresh-token exchange and retry once; if a freshly issued token is still rejected, it
|
||||||
|
surfaces "missing 'user' role in Zitadel" and pauses sync until the user signs in again.
|
||||||
|
|
||||||
|
> **Auth (VPS/.NET):** use the in-house `KunsZitadel` nuget package (feed
|
||||||
|
> `https://git.kuns.dev/api/packages/kuns/nuget/index.json`) — call `AddKunsZitadel(...)`
|
||||||
|
> with the Zitadel authority/audience/client id to wire `JwtBearer` validation + CORS for
|
||||||
|
> the web client origin. (`KunsZitadel` is server-side token *validation* only; the desktop
|
||||||
|
> client acquires tokens via its own OIDC flow.)
|
||||||
|
|
||||||
|
| Method & path | Caller | Body | Response |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PUT /lists` | desktop | `[{ "id", "name", "ownerId"? }]` — the FULL catalog | `200` |
|
||||||
|
| `GET /lists` | web | — | `200 [{ "id", "name", "ownerId"? }]` |
|
||||||
|
| `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) |
|
||||||
|
| `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` |
|
||||||
|
| `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt","ownerId"? }]` |
|
||||||
|
| `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) |
|
||||||
|
| `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description","ownerId"? }]` — full Idle set | `200` |
|
||||||
|
|
||||||
|
`ownerId` (optional, see §1) is the Zitadel `sub` of the owner. The desktop sends it on push
|
||||||
|
and ignores pulled tasks owned by a different user; the server should derive/validate it from
|
||||||
|
the token rather than trust the client value.
|
||||||
|
|
||||||
|
Semantics:
|
||||||
|
|
||||||
|
- **`PUT /lists`** — full replace: upsert all supplied, DELETE any list not in the payload
|
||||||
|
(cascades its tasks). Idempotent.
|
||||||
|
- **`POST /tasks`** — `listId` must exist (`400`/`404` otherwise). Server generates the id.
|
||||||
|
- **`PUT /tasks/mirror`** — full replace of the `imported=true` partition: upsert every task
|
||||||
|
in the payload (insert with `imported=true`, or update), and DELETE any `imported=true`
|
||||||
|
task whose id is not in the payload. `imported=false` rows are untouched. Idempotent.
|
||||||
|
- All task ids are client-trusted within the shared space; the server never rewrites an id.
|
||||||
|
|
||||||
|
## 5. Reconcile loop (desktop, runs each poll cycle)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. PULL: GET /tasks?imported=false
|
||||||
|
for each: if no local task with that id → create local TaskEntity
|
||||||
|
{ Id = remote.id, ListId = remote.listId, Title, Description,
|
||||||
|
Status = Idle, CreatedBy = "online" }
|
||||||
|
(skip + log if remote.listId has no local list)
|
||||||
|
then POST /tasks/{id}/imported
|
||||||
|
2. PUSH LISTS: PUT /lists with the full local catalog [{id, name}]
|
||||||
|
3. PUSH TASKS: PUT /tasks/mirror with the current local Idle backlog set (§2)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ordering matters: pull+import+flag first, so the just-imported tasks are part of the local
|
||||||
|
Idle set computed in step 3 and survive the mirror replace.
|
||||||
|
|
||||||
|
## 6. Minimal web client
|
||||||
|
|
||||||
|
Integrate into the existing Nuxt app at claudedo.kuns.dev if present; else a minimal page.
|
||||||
|
|
||||||
|
- Zitadel login.
|
||||||
|
- Show lists (`GET /lists`); select one to see its Idle tasks (`GET /lists/{id}/tasks`).
|
||||||
|
- Add-task form → `POST /tasks`.
|
||||||
|
- Mobile-first (main use: jotting ideas from a phone).
|
||||||
|
- **Create + read only.** No editing, reordering, status changes, or deletes.
|
||||||
|
|
||||||
|
## 7. Security
|
||||||
|
|
||||||
|
- Every route auth-gated (`401` on bad token); only static assets / login are public.
|
||||||
|
- Validate `listId` on task creation; parameterized queries only.
|
||||||
|
- CORS restricted to the web client origin.
|
||||||
|
- Don't log task titles/descriptions at info level (user content).
|
||||||
|
|
||||||
|
## 8. Deliverables from the VPS build
|
||||||
|
|
||||||
|
Report back so the desktop can be configured:
|
||||||
|
|
||||||
|
1. **API base URL.**
|
||||||
|
2. **Zitadel app/client config the desktop must use**: issuer/authority, client id, scopes,
|
||||||
|
and the OAuth flow to use for a desktop app (device-code or auth-code + PKCE), plus how
|
||||||
|
refresh tokens are issued.
|
||||||
|
3. Any env vars / README.
|
||||||
|
|
||||||
|
Out of scope server-side: task execution (the desktop runs Claude), any task state other
|
||||||
|
than the Idle mirror, multi-user / sharing / notifications.
|
||||||
+61
-271
@@ -1,286 +1,76 @@
|
|||||||
# ClaudeDo — Offene Punkte
|
# ClaudeDo — Offene Punkte
|
||||||
|
|
||||||
Stand: 2026-04-30. Neu erstellt nach Code-Audit gegen `plan.md`, `improvement-plan.md` und `mailbox-proposal.md`.
|
Stand: 2026-07-24. Diese Datei listet die **aktiv verifizierten Findings** aus der Verifikations-Session vom 2026-07-24. Die laufende Verifikations-Checkliste (Pass/Fail je Abschnitt, inkl. noch offener Abschnitte §7–§11 und Kanten) lebt in `docs/verification-handoff.md`. Erledigtes steht in den Commits/im Code, nicht hier.
|
||||||
|
|
||||||
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
|
## Bugs (verifiziert 2026-07-24)
|
||||||
|
|
||||||
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).
|
- **OUTCOME-Karte rendert rohes Structured-Output-JSON:** `TaskMonitorViewModel.ApplyOutcome` (ClaudeDo.Ui) setzt bei Tasks ohne Roadblock-Marker `SessionOutcome = result` wörtlich (Zeile ~261). Der Worker legt in `task.Result` das rohe `{"summary":…,"files_changed":[…]}` ab (die lesbare Fassung steht in `task_runs.resultMarkdown`), also zeigt die Detail-Insel OUTCOME als JSON-Blob statt als Text. Fix: (a) UI parst ein JSON-Result und zeigt `summary`, oder (b) Worker schreibt `summary`/`resultMarkdown` statt des JSON in `task.Result`. Verifiziert am Task `verif §1 diff matrix`. Deckt sich mit Memory `worker_testing_findings`.
|
||||||
|
- **Approve & Merge schluckt einen „blocked"-Merge still:** `DetailsIslandViewModel.ApproveReviewAsync` reagiert nur auf `result.Status == "conflict"` (öffnet den Resolver); bei `"blocked"` (z.B. Ziel-Working-Tree hat uncommittete getrackte Änderungen) und anderen Nicht-`merged`/Nicht-`conflict`-Status passiert **nichts** — kein Footer-Fehler, kein Dialog (der `catch` greift nur bei Exceptions, „blocked" ist aber ein normaler Rückgabewert mit `ErrorMessage`). User sieht „Klick tut nichts". Doppelt verifiziert (Konflikt-Approve UND sauberer additiver Approve `verif §1b`, beide bei dirty `main`-Checkout still). Fix: bei `blocked`/unerwartetem Status `result.ErrorMessage` via `ShowErrorAsync`/`FlashFooterError` surfacen. Verstößt gegen `feedback_ui_error_surfacing`. (Der Konflikt-Resolver + 3-Pane-Editor funktionieren, sobald der Ziel-Tree sauber ist.)
|
||||||
|
- **„Waiting for Improvements" für Planning-Parents (Terminologie):** `taskStatus.waitingForChildren` = „Waiting for Improvements" (en.json:504), `agentStatus.children` dito (503), `childOutcomesLabel` = „IMPROVEMENTS" (191). Seit dem unified-parent-Modell gilt `WaitingForChildren` für Planning **und** Improvement-Parents — „Improvements" ist für einen Planning-Parent falsch. Auf neutrales „Waiting for Subtasks"/„Subtasks" umstellen (en **und** de — Localization.Tests-Parität). Verifiziert §3.
|
||||||
|
- **Kind-Rows aktualisieren nach Parent-Planning-Transitionen nicht live:** Parent-getriebene Änderungen propagieren nicht auf die Kind-`TaskRowViewModel`s ohne Listen-Reload. (a) Nach „Finalize planning" bleiben Kind-Rows auf „Draft" (`IsDraft`) statt „Planned" (`IsPlanned`) — der geänderte `ParentFinalized` kommt nicht über das Parent-`TaskUpdated`-Broadcast an. (b) Nach „Discard" bleiben die (in der DB gelöschten) Draft-Kind-Rows sichtbar, bis die Liste neu geladen wird. Gemeinsame Ursache: die Kinderliste/-zustände werden bei Parent-Transitionen nicht live neu aufgelöst. Doppelt verifiziert §3 (Finalize **und** Discard, 2026-07-24).
|
||||||
|
- **AskUser-Frage erscheint NUR in Mission Control, nicht in der Task-Detail-Insel:** Der `ask_user`-Banner (Frage + Antwort-Eingabe) lebt ausschließlich in `MonitorPaneView` (Mission Control). Die Detail-Insel (`DetailsIslandView`) streamt zwar den Live-Log eines laufenden Tasks, zeigt aber **kein** Frage-Banner — ein Nutzer, der nur die Detailansicht offen hat, sieht nicht, dass der Run auf eine Antwort wartet, und läuft nach 3 min in den Timeout-Fallback. Verifiziert §7 (User schaute Detailansicht, Frage war unsichtbar; erst in Mission Control sichtbar). Fix: Frage-Banner + Inline-Antwort auch in der Detail-Insel für den gebundenen laufenden Task surfacen (VM-Zustand liegt bereits in `TaskMonitorViewModel`, müsste für die Detail-Insel repliziert/geteilt werden). **Von Mika bei der §7-Verifikation ausdrücklich gewünscht** („vermisse diese Interaktion in der Detail-Ansicht"). §7.
|
||||||
|
- **„New session"-Button (Mission Control) unsichtbar — Icon.Plus ist Strich-Only:** `Icon.Plus` = `M12 5v14M5 12h14` (IslandStyles.axaml:61) ist reine Linien-Geometrie ohne Fläche; im `<PathIcon>` (MissionControlView.axaml:38, füllt Geometrie) rendert sie **unsichtbar** → der Ad-hoc-„New session"-Button erscheint leer und ist nicht auffindbar (Feature + VM `OpenAdHocConPtySessionAsync` + Tests funktionieren, nur das Icon fehlt sichtbar). Fix: Icon.Plus als gefüllte Geometrie authoren ODER als gestricheltes `Path` rendern (Icon-Gotcha in CLAUDE.md). **Andere `Icon.Plus`-Verwendungen mitprüfen.** Verifiziert §5.
|
||||||
|
|
||||||
| Slice | Worker-Anker | UI-Anker | Status |
|
## UX / Nits (verifiziert 2026-07-24)
|
||||||
|---|---|---|---|
|
|
||||||
| **Planning Sessions** (Plan B+C) | `Planning/PlanningSessionManager`, `PlanningChainCoordinator`, `PlanningMcpService` | `Views/Planning/PlanningDiffView`, `ConflictResolutionView`, `UnfinishedPlanningModalView` | ✅ Code, manuelle Verifikation siehe §1.1 |
|
- **„Resume planning session" ist grundsätzlich kaputt (Session-Id wird nie erfasst) + Fehler wird verschluckt:** Im UnfinishedPlanning-Modal macht **Resume** nichts. Ursache: `PlanningSessionManager.ResumeAsync` (PlanningSessionManager.cs:238) bricht mit `InvalidOperationException("No Claude session ID captured yet; cannot resume.")` ab, wenn `task.PlanningSessionId` leer ist — und der Setter `TaskRepository.UpdatePlanningSessionIdAsync` (TaskRepository.cs:322) hat **keinen einzigen Aufrufer** im Worker, d.h. `planning_session_id` wird nie befüllt (verifiziert an `verif §3 resume-discard`: `planning_session_id=NULL`, Token gesetzt, Drafts angelegt). Resume kann also **nie** erfolgreich sein. Zusätzlich verschluckt `TasksIslandViewModel.ResumePlanningSessionAsync` den ganzen Block in einem leeren `catch { }` (~Zeile 870) → der Button ist ein **stummer No-op**, kein Footer-Fehler, kein Dialog (verstößt gegen `feedback_ui_error_surfacing`). Hintergrund: die wt-Planning-Session ist interaktiv; ClaudeDo erfasst die claude-Session-Id dort nicht (analog zur bewusst nicht persistierten ConPTY-Session-Id, §5). Fix-Optionen: (a) beim Planning-Start/-Lauf die claude-Session-Id erfassen und via `UpdatePlanningSessionIdAsync` persistieren, damit Resume echt resumen kann; ODER (b) Resume entfernen/deaktivieren, wenn keine Session-Id vorliegt; in JEDEM Fall den Fehler statt des leeren `catch` surfacen. Verifiziert §3. §3.
|
||||||
| **Prime Claude** (geplante Recurrence) | `Prime/PrimeScheduler`, `NextDueCalculator`, `PrimeRunner` | `ViewModels/Modals/PrimeClaudeTabViewModel`, `Views/Controls/ThemedDatePicker` | ✅ Code, manuelle Verifikation siehe §1.2 |
|
- **Attachments: erster Drag&Drop der Session schlug einmalig fehl („An error occurred"):** Beim allerersten Drop-to-attach einer UI-Session zeigte die `DropStatus`-Zeile inline einen generischen Fehler und es wurde nichts persistiert (kein File, keine DB-Row); alle folgenden Drops derselben Session + der „Add file…"-Picker + Remove funktionierten fehlerfrei. Nicht reproduzierbar nach dem ersten Mal (App-Neustart nötig, um die „erster-Drop"-Bedingung wiederherzustellen). Kandidaten: transiente SQLite-Contention (der UI-Prozess schreibt `todo.db` direkt via `new TaskAttachmentRepository`, während der Worker dieselbe DB hält) ODER ein Fehler im Drop-Stream-Pfad (`IStorageFile.OpenReadAsync` im Code-Behind, außerhalb des `try` in `AddFilesAsync`). Falls es erneut auftritt: `AddFilesAsync`/`OnDrop` mit robusterem Error-Logging versehen (die genaue Exception-Message fehlt, weil `DropStatus` nur `{fileName}: {ex.Message}` zeigt). Verifiziert §9 (einmalig beobachtet). §9.
|
||||||
| **Self-Update System** (Gitea Releases) | — | `ClaudeDo.Releases` (`ReleaseClient`, `SelfUpdater`, `ChecksumVerifier`, `VersionComparer`), `ClaudeDo.Installer` (Pages/Steps/Core) | ✅ Code, manuelle Verifikation siehe §1.3 |
|
- **Agent-Settings-Gear weicht vom übrigen Settings-Icon ab:** Der Agent-Settings-Flyout-Button (`TaskHeaderBar.axaml:68`) rendert ein Unicode-Glyph `⚙` (`<TextBlock Text="⚙">`), während die Listen-Nav (`ListsIslandView.axaml:56`) und die Listen-Settings (`TasksIslandView.axaml:47`) das gefüllte `Icon.Settings`-PathIcon (Gear-StreamGeometry, IslandStyles.axaml:110) nutzen → optisch ein anderes Zahnrad. Angleichen: den `⚙`-TextBlock durch `<PathIcon Data="{StaticResource Icon.Settings}" .../>` ersetzen. Verifiziert §8 (User-Sichtprüfung). §8.
|
||||||
| **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 |
|
- **Session-Skills-Tab hat keinen Empty-State:** Bei 0 installierten Skills zeigt der Skills-Tab (Settings) nur eine nackte leere Fläche unter der Install-Zeile — kein erklärender Hinweis (z.B. „Noch keine Skills installiert — GitHub-URL oben einfügen"). Verifiziert §8 (frischer Zustand vor ponytail-Install). Kleiner Empty-State-Text ergänzen.
|
||||||
| **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` |
|
- **Diff-Viewer: reiner Rename schwach dargestellt:** Rename korrekt erkannt (`UnifiedDiffParser` → `DiffFileStatus.Renamed`, Badge `StatusCode="R"`), aber der File-Tree zeigt nur den neuen Namen + „+0 −0" ohne „alt → neu"-Pfad; liest sich wie „keine Änderung". Optional: alten Pfad + „renamed"-Label anzeigen. §1.
|
||||||
| **Worker-State-Konsolidierung** | `TaskStateService` ist alleiniger Owner von `Status`/`PlanningPhase`/`BlockedByTaskId`-Writes; `OverrideSlotService` ausgelagert; `QueueWaker` + `QueuePicker` getrennt | — | ✅ Code |
|
- **Header-TurnsText zeigt `0/max` für abgeschlossene Runs:** `TurnsText => {Turns}/{EffectiveMaxTurns}` — `Turns` wird beim Laden eines terminalen Tasks nicht aus `task_runs.turnCount` restauriert (nur live gefüllt). Kosmetisch. §1.
|
||||||
| **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 |
|
- **Conflict-Resolver: Continue/Merge-Button klickbar trotz offener Konflikte:** Gate greift funktional (merged erst wenn alle Konflikte in allen Dateien gelöst), aber der Button ist nicht disabled/gegraut → früher Klick ist ein stummer No-op. Besser: disabled bis `AllResolved`, oder Hinweis „N Konflikte in M Dateien offen". §4.
|
||||||
|
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar:** Beim 2-Datei-Konflikt schwer zu sehen, dass zwei Dateien betroffen sind (File-Switcher/Anzahl zu unauffällig). Prominentere Datei-Liste / „x von y Dateien". §4.
|
||||||
|
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber der Row-Status-Chip zeigt „Idle"; der `PlanningBadge` ersetzt das nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".) §3.
|
||||||
|
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt die Reihenfolge/Abhängigkeit nicht dar. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>"), nicht nur der „waiting"-Chip nach dem Queueen. §3.
|
||||||
|
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks`, `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind. §3.
|
||||||
|
- **„Open ConPTY session" erneut = Prompt wird neu gesendet, kein Resume:** Da die ConPTY-Session-Id bewusst nicht persistiert wird, startet ein erneutes „Open ConPTY session" auf demselben Task eine frische Session und sendet den Task-Prompt erneut (re-runt die Arbeit im selben Worktree) statt zu resumen. So designt, aber UX-Falle — evtl. „Resume"-Affordance oder Re-Open-Warnung. §5.
|
||||||
|
|
||||||
|
## Feature-Wünsche (aus der Session)
|
||||||
|
|
||||||
|
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen). §4.
|
||||||
|
- Als ClaudeDo-Tasks in Liste „Claude do" erfasst: **Approve erzwingt Diff/Review vor Merge** (koppelt den blocked-Merge-Silent-Fail-Fix) und **interaktive Planning-Session über embedded ConPTY statt externem wt-Fenster** (koppelt den Planning-Permission-Prompt).
|
||||||
|
|
||||||
|
## Sichtprüfung offen (2026-07-27, Batch „Claude do"-Liste)
|
||||||
|
|
||||||
|
Alles gebaut + unit-getestet, aber **nicht visuell verifiziert** — Mika prüft:
|
||||||
|
|
||||||
|
- **Ctrl+K fokussiert die Suche, `#` ist wieder tippbar** (Binding war `OemQuestion` = `#` auf DE-Layout).
|
||||||
|
- **Titel-Edit in der Detail-Insel persistiert** (400 ms debounced, wie die Beschreibung); Row-Titel + Merge-Kontext ziehen mit.
|
||||||
|
- **Spinner beim ConPTY-Start:** Tile erscheint sofort mit „Sitzung wird gestartet…" und deckt Launch-Spec-Roundtrip + Spawn ab. **Nicht** abgedeckt: die Sekunden, die die claude-TUI danach zum ersten Frame braucht (dafür bräuchte es einen Hook auf `TerminalControl.DataReceived`). Bei Launch-Fehler bleibt das Tile jetzt mit Inline-Banner stehen statt zu verschwinden.
|
||||||
|
- **Spinner beim Refine** ersetzt den Refine-Button in der Row, solange der Run läuft.
|
||||||
|
- **Interactive-Chip** statt „Parked" auf Tasks mit offener ConPTY-Session; Klick öffnet Mission Control und fokussiert die Pane. Accent-Tint (bewusst dieselbe „live"-Familie wie Running, klar unterschieden vom slate-blauen Parked).
|
||||||
|
- **Diff-Viewer:** rechter Abstand der `+n −n`-Zahlen im File-Tree.
|
||||||
|
- **Manual-Tasks:** MANUAL-Badge, Kontextmenü-Toggle, Listen-Checkbox „Manuelle Liste"; Queue/Refine/Planning ausgeblendet, ConPTY bleibt.
|
||||||
|
- **Settings → Allgemein:** Tabelle „Vorgaben pro Modell" (Effort + Max. Durchläufe je haiku/sonnet/opus/fable). Ersetzt das einzelne globale „Max. Durchläufe"-Feld.
|
||||||
|
|
||||||
|
Offene Entscheidungen dazu:
|
||||||
|
|
||||||
|
- Interaktive ConPTY-Sessions bekommen `--effort`, aber **kein** `--model` — die Session läuft weiter unter dem Modell aus Mikas Claude-Config, der Effort kommt aus dem Preset des Modells, das ClaudeDo für die Task auflösen würde. Falls ClaudeDo auch interaktiv das Modell erzwingen soll, ist das ein Folge-Task.
|
||||||
|
- `AppSettings.DefaultMaxTurns` ist nur noch Fallback für ein Modell ohne Preset-Zeile und hat keinen Editor mehr. Spalte könnte später entfallen.
|
||||||
|
|
||||||
|
## Beobachtung (offen — Entscheidung Mika)
|
||||||
|
|
||||||
|
- **`--permission-mode auto` + Modell `haiku` → Writes werden denied:** Kontrolliert verifiziert (CLI 2.1.207): unter dem Default-Mode `auto` bekommt **sonnet** Writes auto-approved (`permission_denials:[]`), **haiku** wird `denied` (`permission_denials:[Write]`, keine Datei) — eine haiku-Task macht unter `auto` still nichts und landet ohne Änderung in `WaitingForReview`. Normalbetrieb (Default = sonnet) nicht betroffen. KEINE CLI-Regression, sondern modellabhängiges `auto`-Verhalten. Optionen falls es nervt: haiku aus der Auswahl nehmen, ODER Runner auf `acceptEdits`/`bypassPermissions` (modell-unabhängig). Mika: erstmal beobachten. Siehe Memory `auto_permission_haiku_footgun`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Verification (vor allem anderen)
|
## Offene Verifikation (2026-07-27)
|
||||||
|
|
||||||
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".
|
- **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer,
|
||||||
|
the context-menu item appears only on lists with a working dir, and the selection dialog has no
|
||||||
### 1.0 Plan-Verification 1–13
|
LIST column. Plus a real-Claude smoke run of the five phases (dedupe questions, enhancements
|
||||||
|
landing in task descriptions, queued execution, merges).
|
||||||
| # | 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
|
## Bewusst verworfen (nicht erneut vorschlagen)
|
||||||
|
|
||||||
### 2.1 Folder-Picker für `Working Directory` ⬜
|
- **CI-Build/Test-Pipeline** — push-to-main + release-on-push deckt das ab; Tests laufen am Ende jeder Session.
|
||||||
- **Datei:** `Views/ListSettingsModalView.axaml` + zugehöriges VM
|
- **Real-`claude`-Smoke-Test als xUnit-Test** — kein Claude in `dotnet test`; bleibt manueller Check. Tests nutzen `FakeClaudeProcess`.
|
||||||
- **Aktuell:** plain `TextBox` — Pfad muss getippt werden.
|
- **`architecture.md` / ADRs** — die per-Projekt-`CLAUDE.md`-Dateien sind die lebende Doku.
|
||||||
- **Soll:** Button „…" daneben → öffnet `IStorageProvider.OpenFolderPickerAsync`, schreibt Pfad ins Feld.
|
- **Task-Mailbox-Integration** — geparkt; das generische `mcp__mailbox__*`-Plugin reicht (`mailbox-proposal.md`).
|
||||||
- **Aufwand:** klein.
|
- **Tag-Negation, Tag-Multi-Select, Notes-`lists.kind`-Switch, Install-Service-Skript** — durch die aktuelle Architektur überholt.
|
||||||
|
|
||||||
### 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 Result + Description ✅
|
|
||||||
- `Views/MarkdownView.axaml` + Detail-Pane verwenden Markdown.Avalonia.
|
|
||||||
|
|
||||||
### 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 🟡
|
|
||||||
- `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:** `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 ✅
|
|
||||||
- `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 ✅
|
|
||||||
- `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 ⬜
|
|
||||||
- **Datei:** `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`
|
|
||||||
- **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 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 ⬜
|
|
||||||
- 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
|
|
||||||
|
|
||||||
### 4.1 Worker-Autostart via Startup-Shortcut ✅ (ersetzt Scheduled Task + Windows-Service)
|
|
||||||
- Der Worker läuft als `WinExe` (kein Konsolenfenster) + Serilog-File-Sink (`~/.todo-app/logs/worker-*.log`) + Single-Instance-Mutex.
|
|
||||||
- Autostart über eine **Startup-Ordner-Verknüpfung** `ClaudeDo Worker.lnk` (`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\`), die der Installer via `AutostartShortcut`/`ShortcutFactory` COM-Helper anlegt. Kein Scheduled Task, kein Windows-Service.
|
|
||||||
- `StartWorkerStep` startet den Worker per `Process.Start`; `StopWorkerStep` beendet ihn per prozessbasiertem Kill.
|
|
||||||
- Die App (`IslandsShellViewModel`) startet den Worker nicht selbst. Bei offline-Worker ~12s nach App-Start: einmaliges `WorkerConnectionModal` (Start Worker / Rerun Installer / Dismiss); Connection-Status-Pill in der Fußzeile ist ein Button zum erneuten Öffnen des Modals.
|
|
||||||
- `UninstallRunner` löscht die Startup-`.lnk`; migriert ältere Installs durch best-effort-Löschen des Legacy-Scheduled-Tasks „ClaudeDoWorker" und des Legacy-Windows-Service.
|
|
||||||
- **Manuelle E2E-Verifikation am Gerät ausstehend** (Logoff/Logon-Autostart, Update-Pfad, Uninstall).
|
|
||||||
|
|
||||||
### 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`
|
|
||||||
- **Inhalt:** `dotnet publish` + `sc.exe create` + `sc.exe failure` + Hinweis auf `obj=` (User-Account) wegen Claude-CLI-Session.
|
|
||||||
- **Aufwand:** klein.
|
|
||||||
|
|
||||||
### 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 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 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 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, wie Self-Update.
|
|
||||||
|
|
||||||
### 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 ⬜
|
|
||||||
- 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 | 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) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Improvement Plan (improvement-plan.md, Stand 2026-04-13)
|
|
||||||
|
|
||||||
| 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` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ToDo-App mit autonomem Agent-Worker — Design
|
# ToDo-App mit autonomem Agent-Worker — Design
|
||||||
|
|
||||||
|
> **Hinweis (2026-06-09):** Historisches Design-Dokument vom Projektstart — bewusst nicht nachgepflegt. Überholt sind insbesondere: die Tag-basierte Queue (entfernt; der Picker nutzt `Status=Queued` + `BlockedByTaskId IS NULL`), `schema.sql` (Schema läuft über EF-Core-Migrations) und das Projektlayout (inzwischen sechs Testprojekte). Lebende Doku sind die `CLAUDE.md`-Dateien pro Projekt.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
Ziel: eine persönliche ToDo-App als Desktop-Anwendung, in der mehrere Listen verwaltet werden können. Ein Teil der Tasks soll autonom von Claude abgearbeitet werden (z.B. Recherche, Code-Aufgaben, Notizen-Verarbeitung). Die Autonomie läuft in einem getrennten Hintergrund-Prozess, damit die UI davon entkoppelt bleibt.
|
Ziel: eine persönliche ToDo-App als Desktop-Anwendung, in der mehrere Listen verwaltet werden können. Ein Teil der Tasks soll autonom von Claude abgearbeitet werden (z.B. Recherche, Code-Aufgaben, Notizen-Verarbeitung). Die Autonomie läuft in einem getrennten Hintergrund-Prozess, damit die UI davon entkoppelt bleibt.
|
||||||
|
|||||||
@@ -7,6 +7,22 @@ Snapshot of every string ClaudeDo sends to Claude CLI, plus the CLI-flag surface
|
|||||||
|
|
||||||
Date: 2026-04-24
|
Date: 2026-04-24
|
||||||
|
|
||||||
|
> **Update 2026-06-04 — prompts externalized.** All prose prompts now live as
|
||||||
|
> editable files under `~/.todo-app/prompts/`, each seeded from a bundled default in
|
||||||
|
> `src/ClaudeDo.Data/PromptFiles.cs` (read via `ReadOrDefault` / `Render`, which
|
||||||
|
> substitutes only named `{tokens}`):
|
||||||
|
> `system.md`, `planning-system.md`, `planning-initial.md` (`{title}`/`{description}`),
|
||||||
|
> `retry.md`, `daily-prep.md` (`{date}`/`{maxTasks}`), `weekly-report.md`
|
||||||
|
> (`{start}`/`{end}`; German output). The old `agent.md` and `planning.md` are
|
||||||
|
> retired — `system.md` is the single appended system prompt (the agent/manual split
|
||||||
|
> is gone), and the planning system prompt is `planning-system.md`. Daily-prep and
|
||||||
|
> retry prompts are now English; retry leans on the resumed session and appends the
|
||||||
|
> captured stderr only when it's a real error (not the generic "exited with code N").
|
||||||
|
> The system prompt instructs the agent to emit `CLAUDEDO_BLOCKED: <reason>` on its
|
||||||
|
> own line for any true blocker; `StreamAnalyzer` collects every marker, strips them
|
||||||
|
> from the result, and `TaskRunner` folds them into the review result as a
|
||||||
|
> "⚠ Roadblocks" section. All six prompt files are editable from Settings → Files.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Task-execution prompts (agent-tagged tasks → Claude CLI)
|
## 1. Task-execution prompts (agent-tagged tasks → Claude CLI)
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Waiting for Review — Task State — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a `WaitingForReview` lifecycle state that standalone tasks enter after a successful run, with approve / reject-rerun / reject-park / cancel exits, exposed via UI and MCP.
|
||||||
|
|
||||||
|
**Architecture:** New enum value + nullable `ReviewFeedback` column. `TaskStateService` gains review transitions. `TaskRunner.HandleSuccess` routes standalone-task success to review. `QueueService.RunInSlotAsync` resumes the Claude session when re-running a rejected task. New MCP `review_task` tool + UI commands.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, EF Core (SQLite, TEXT enum), SignalR, Avalonia MVVM, xUnit.
|
||||||
|
|
||||||
|
**Scope decision (locked):** Only standalone tasks (`ParentTaskId == null`) route to `WaitingForReview`. Planning **child** tasks continue to `Done` on success so the sequential planning chain (which advances on *terminal* states) is unaffected. Flagged for user confirmation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Data layer — enum, converter, column
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`
|
||||||
|
- Create: EF migration via CLI
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add `WaitingForReview` to `TaskStatus` enum (after `Running`) and add `public string? ReviewFeedback { get; set; }` to `TaskEntity`.
|
||||||
|
- [ ] **Step 2:** In `TaskEntityConfiguration`, add `TaskStatus.WaitingForReview => "waiting_for_review"` to `StatusToString` and `"waiting_for_review" => TaskStatus.WaitingForReview` to `StatusFromString`; map the column: `builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");`
|
||||||
|
- [ ] **Step 3:** Create migration: `dotnet ef migrations add AddReviewFeedback --project src/ClaudeDo.Data/ClaudeDo.Data.csproj`. Verify it only adds the `review_feedback` TEXT column (nullable). If `dotnet ef` unavailable, hand-write the migration + designer following the latest migration in `Migrations/`.
|
||||||
|
- [ ] **Step 4:** Build `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`. Expected: success.
|
||||||
|
- [ ] **Step 5:** Commit.
|
||||||
|
|
||||||
|
## Task 2: Worker — review transitions in TaskStateService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/State/TaskStateService.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs` (add new method signatures)
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/...` (state transition tests)
|
||||||
|
|
||||||
|
New methods (all return `TransitionResult`, broadcast `TaskUpdated`):
|
||||||
|
|
||||||
|
- `SubmitForReviewAsync(taskId, finishedAt, result, ct)` — guard `Status == Running`; set `Status=WaitingForReview, FinishedAt, Result`. Does NOT call `OnChildTerminalAsync` (review is non-terminal; only invoked for standalone tasks anyway).
|
||||||
|
- `ApproveReviewAsync(taskId, ct)` — guard `Status == WaitingForReview`; set `Status=Done`.
|
||||||
|
- `RejectToQueueAsync(taskId, feedback, ct)` — reject empty/whitespace feedback (`TransitionResult(false, "Feedback is required to reject for re-run.")`); guard `Status == WaitingForReview`; set `Status=Queued, ReviewFeedback=feedback`; `_waker.Wake()`.
|
||||||
|
- `RejectToIdleAsync(taskId, ct)` — guard `Status == WaitingForReview`; set `Status=Idle, ReviewFeedback=null` (leave `Result` intact).
|
||||||
|
- `ClearReviewFeedbackAsync(taskId, ct)` — set `ReviewFeedback=null` (no status change, no guard); used by the runner after consuming feedback.
|
||||||
|
- Extend `CancelAsync` guard: `(Status == Running || Status == Queued || Status == WaitingForReview)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Write failing tests in a new `tests/ClaudeDo.Worker.Tests/State/ReviewTransitionTests.cs` (follow existing TaskStateService test setup). Cover: submit-for-review from Running; approve from WaitingForReview→Done; reject-to-queue stores feedback + status Queued; empty feedback rejected; reject-to-idle clears feedback + keeps Result; cancel from WaitingForReview→Cancelled; invalid (approve from Idle) returns `!Ok`.
|
||||||
|
- [ ] **Step 2:** Run tests, expect FAIL (methods missing).
|
||||||
|
- [ ] **Step 3:** Implement the methods + interface signatures + CancelAsync guard.
|
||||||
|
- [ ] **Step 4:** Run tests, expect PASS.
|
||||||
|
- [ ] **Step 5:** Commit.
|
||||||
|
|
||||||
|
## Task 3: Worker — route standalone success to review
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` (`HandleSuccess`)
|
||||||
|
|
||||||
|
- [ ] **Step 1:** In `HandleSuccess`, after commit, branch:
|
||||||
|
```csharp
|
||||||
|
var finishedAt = DateTime.UtcNow;
|
||||||
|
if (task.ParentTaskId is null)
|
||||||
|
{
|
||||||
|
await _state.SubmitForReviewAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
||||||
|
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||||
|
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _state.CompleteAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
||||||
|
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||||
|
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] **Step 2:** Build worker. Expected: success.
|
||||||
|
- [ ] **Step 3:** Commit.
|
||||||
|
|
||||||
|
## Task 4: Worker — resume-aware re-run in QueueService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Queue/QueueService.cs` (`RunInSlotAsync`)
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/...`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** In `RunInSlotAsync`, after loading `task`:
|
||||||
|
```csharp
|
||||||
|
if (!string.IsNullOrWhiteSpace(task.ReviewFeedback))
|
||||||
|
{
|
||||||
|
var feedback = task.ReviewFeedback!;
|
||||||
|
string? sessionId;
|
||||||
|
using (var ctx = _dbFactory.CreateDbContext())
|
||||||
|
sessionId = (await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, ct))?.SessionId;
|
||||||
|
await _state.ClearReviewFeedbackAsync(taskId, ct); // inject ITaskStateService
|
||||||
|
if (sessionId is not null)
|
||||||
|
{
|
||||||
|
await _runner.ContinueAsync(taskId, feedback, "queue", ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
task.Description = string.IsNullOrWhiteSpace(task.Description)
|
||||||
|
? $"Reviewer feedback: {feedback}"
|
||||||
|
: $"{task.Description}\n\nReviewer feedback: {feedback}";
|
||||||
|
}
|
||||||
|
await _runner.RunAsync(task, "queue", ct);
|
||||||
|
```
|
||||||
|
Inject `ITaskStateService _state` into `QueueService` (add to ctor + DI already provides it).
|
||||||
|
- [ ] **Step 2:** Build worker, expect success.
|
||||||
|
- [ ] **Step 3:** Commit.
|
||||||
|
|
||||||
|
## Task 5: MCP — review_task tool + status reference
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add `review_task` tool:
|
||||||
|
```csharp
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Review a task that is WaitingForReview. decision: 'approve' (→ Done), " +
|
||||||
|
"'reject_rerun' (→ Queued, resumes the agent session with feedback — feedback required), " +
|
||||||
|
"'reject_park' (→ Idle for manual editing), 'cancel' (→ Cancelled). ")]
|
||||||
|
public async Task<TaskDto> ReviewTask(string taskId, string decision, string? feedback, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
TransitionResult r = decision.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"approve" => await _state.ApproveReviewAsync(taskId, cancellationToken),
|
||||||
|
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
|
||||||
|
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
|
||||||
|
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
|
||||||
|
_ => throw new InvalidOperationException($"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
|
||||||
|
};
|
||||||
|
if (!r.Ok) throw new InvalidOperationException(r.Reason ?? "Review action failed.");
|
||||||
|
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] **Step 2:** Add `WaitingForReview` to `GetTaskStatusValues` list; update the validation strings in `ListTasks` and the lifecycle text in `GetTask`/`UpdateTaskStatus` to include `WaitingForReview`.
|
||||||
|
- [ ] **Step 3:** Build worker, expect success.
|
||||||
|
- [ ] **Step 4:** Commit.
|
||||||
|
|
||||||
|
## Task 6: UI — client + hub methods
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Hub: add `ApproveReview(taskId)`, `RejectReviewToQueue(taskId, feedback)`, `RejectReviewToIdle(taskId)`, `CancelReview(taskId)` — each calls the matching `_state` method via `HubGuard`-style mapping (`if (!result.Ok) throw new HubException(...)`).
|
||||||
|
- [ ] **Step 2:** `IWorkerClient` + `WorkerClient`: add `ApproveReviewAsync`, `RejectReviewToQueueAsync(taskId, feedback)`, `RejectReviewToIdleAsync`, `CancelReviewAsync` invoking the hub methods. Add no-op/stub impls to `StubWorkerClient`.
|
||||||
|
- [ ] **Step 3:** Build App + Ui.Tests. Expected: success.
|
||||||
|
- [ ] **Step 4:** Commit.
|
||||||
|
|
||||||
|
## Task 7: UI — converter, row VM, view buttons
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Converters/StatusColorConverter.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (commands)
|
||||||
|
- Modify: the task row/detail AXAML to surface Approve / Reject / Park / Cancel when `IsWaitingForReview`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `StatusColorConverter`: add `"waiting_for_review" => Brushes.MediumPurple,` (placeholder — user does visual pass).
|
||||||
|
- [ ] **Step 2:** `TaskRowViewModel`: add `public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;`, raise it in `OnStatusChanged`, and add `(TaskStatus.WaitingForReview, _) => "review"` to `StatusChipClass`.
|
||||||
|
- [ ] **Step 3:** `TasksIslandViewModel`: add relay commands `ApproveReview`, `RejectReviewRerun` (prompts for feedback), `RejectReviewPark`, `CancelReview` operating on the selected/target row, calling the new client methods.
|
||||||
|
- [ ] **Step 4:** Add buttons to the relevant view bound to those commands, visible when `IsWaitingForReview`. Reject-rerun uses a text-input flyout/dialog for required feedback.
|
||||||
|
- [ ] **Step 5:** Build App + Ui.Tests. Expected: success. (Visual layout: flagged for user's visual pass — cannot render here.)
|
||||||
|
- [ ] **Step 6:** Commit.
|
||||||
|
|
||||||
|
## Task 8: Docs + full verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: root `CLAUDE.md`, `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Worker/CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Update status flow lines + worker transition table to include `WaitingForReview` and the new transitions.
|
||||||
|
- [ ] **Step 2:** Build all projects (csproj individually — `.slnx` needs .NET 9) and run `dotnet test tests/ClaudeDo.Worker.Tests`, `tests/ClaudeDo.Ui.Tests`, `tests/ClaudeDo.Data.Tests`. Expected: all green.
|
||||||
|
- [ ] **Step 3:** Commit.
|
||||||
|
|
||||||
|
## Self-Review notes
|
||||||
|
|
||||||
|
- Spec coverage: §1 state machine → Tasks 2,3; §2 data → Task 1; §3 transitions → Task 2; §4 resume → Task 4; §5 MCP → Task 5; §6 hub → Task 6; §7 UI → Tasks 6,7; §8 docs → Task 8; testing → Tasks 2,4,8.
|
||||||
|
- Method names consistent across tasks: `SubmitForReviewAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync` (state); `ApproveReview`/`RejectReviewToQueue`/`RejectReviewToIdle`/`CancelReview` (hub); `ApproveReviewAsync`/`RejectReviewToQueueAsync`/`RejectReviewToIdleAsync`/`CancelReviewAsync` (client).
|
||||||
@@ -0,0 +1,983 @@
|
|||||||
|
# Prime Recurring Weekday Schedule — 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:** Replace the Prime schedule's date-range model with a recurring weekday model — pick a set of weekdays plus a time, and the ping fires on the next eligible day the worker is running.
|
||||||
|
|
||||||
|
**Architecture:** A `[Flags] PrimeDays` weekday bitmask stored as a single `days_of_week` int column replaces `StartDate`/`EndDate`/`WorkdaysOnly`. `NextDueCalculator` walks forward to the next selected weekday; the existing 30-minute catch-up and already-fired-today logic are untouched. UI swaps the range picker + Mon–Fri checkbox for seven toggle buttons. Both SignalR DTO copies carry a single `int Days`.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, EF Core (SQLite), Avalonia 12 (CommunityToolkit.Mvvm), SignalR, xUnit.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-02-prime-recurring-weekdays-design.md`
|
||||||
|
|
||||||
|
**Build/test note:** `dotnet build ClaudeDo.slnx` needs .NET 9; on .NET 8 build individual csproj. Commands in this plan use the per-project form.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Data/Models/PrimeDays.cs` — **new**, `[Flags]` enum.
|
||||||
|
- `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs` — swap fields.
|
||||||
|
- `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs` — column mapping.
|
||||||
|
- `src/ClaudeDo.Data/Migrations/*` — new migration + snapshot.
|
||||||
|
- `src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs` — upsert fields + ordering.
|
||||||
|
- `src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs` — `int Days`.
|
||||||
|
- `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs` — weekday eligibility.
|
||||||
|
- `src/ClaudeDo.Worker/Prime/PrimeScheduler.cs` — `ToDto` mapping.
|
||||||
|
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — list/upsert mapping.
|
||||||
|
- `src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs` — `int Days`.
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs` — 7 day bools.
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs` — defaults + validation.
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` — `day-toggle` style class.
|
||||||
|
- `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` — row template.
|
||||||
|
- Tests: `NextDueCalculatorTests`, `PrimeSchedulerTests`, `PrimeScheduleRepositoryTests`, `PrimeClaudeTabViewModelTests`.
|
||||||
|
- Docs: `src/ClaudeDo.Data/CLAUDE.md`, root `CLAUDE.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: PrimeDays enum + entity + configuration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Data/Models/PrimeDays.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the flags enum**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Data/Models/PrimeDays.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum PrimeDays
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Monday = 1,
|
||||||
|
Tuesday = 2,
|
||||||
|
Wednesday = 4,
|
||||||
|
Thursday = 8,
|
||||||
|
Friday = 16,
|
||||||
|
Saturday = 32,
|
||||||
|
Sunday = 64,
|
||||||
|
Weekdays = Monday | Tuesday | Wednesday | Thursday | Friday, // 31
|
||||||
|
All = Weekdays | Saturday | Sunday, // 127
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Swap entity fields**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs`, remove `StartDate`, `EndDate`, `WorkdaysOnly` and add `Days`. Result:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
public sealed class PrimeScheduleEntity
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public PrimeDays Days { get; set; } = PrimeDays.Weekdays;
|
||||||
|
public TimeSpan TimeOfDay { get; set; }
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public DateTimeOffset? LastRunAt { get; set; }
|
||||||
|
public string? PromptOverride { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update entity configuration**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs`, replace the `start_date`/`end_date`/`workdays_only` property lines with a `days_of_week` mapping (EF maps the enum to INTEGER automatically):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Property(s => s.Days).HasColumnName("days_of_week")
|
||||||
|
.IsRequired().HasDefaultValue(PrimeDays.Weekdays);
|
||||||
|
builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").IsRequired();
|
||||||
|
builder.Property(s => s.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true);
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave `Id`, `LastRunAt`, `PromptOverride`, `CreatedAt` mappings unchanged. Add `using ClaudeDo.Data.Models;` if not present (it already is).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the Data project**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
|
||||||
|
Expected: FAILS — `PrimeScheduleRepository`, snapshot, etc. still reference removed fields. That is expected; Tasks 2–3 fix it. (If you prefer a clean build gate, proceed to Task 2 before building.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/Models/PrimeDays.cs src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs
|
||||||
|
git commit -m "feat(data): model Prime schedule as weekday bitmask"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Repository
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `ListAsync` ordering**
|
||||||
|
|
||||||
|
The old ordering used `StartDate`. Order by `TimeOfDay`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var rows = await _context.PrimeSchedules.AsNoTracking()
|
||||||
|
.OrderBy(s => s.TimeOfDay)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `UpsertAsync` field copy**
|
||||||
|
|
||||||
|
Replace the three removed-field assignments with `Days`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.Days = entity.Days;
|
||||||
|
existing.TimeOfDay = entity.TimeOfDay;
|
||||||
|
existing.Enabled = entity.Enabled;
|
||||||
|
existing.PromptOverride = entity.PromptOverride;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave `GetAsync`, `DeleteAsync`, `UpdateLastRunAsync` unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit** (build verified after migration in Task 3)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs
|
||||||
|
git commit -m "feat(data): persist weekday bitmask in prime schedule repo"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: EF migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_PrimeWeekdays.cs` (generated)
|
||||||
|
- Modify: `src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs` (generated)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate the migration**
|
||||||
|
|
||||||
|
Run from repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet ef migrations add PrimeWeekdays --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: a new `*_PrimeWeekdays.cs` file and an updated snapshot. (If `dotnet ef` is unavailable, hand-write the migration using the body below.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the generated `Up` body with an explicit backfill**
|
||||||
|
|
||||||
|
EF's auto-generated drop/add would discard existing schedules' weekday intent. Edit the new migration's `Up` to add the column, backfill from `workdays_only`, then drop the old columns:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "days_of_week",
|
||||||
|
table: "prime_schedules",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 31);
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"UPDATE prime_schedules SET days_of_week = CASE WHEN workdays_only = 1 THEN 31 ELSE 127 END;");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "start_date", table: "prime_schedules");
|
||||||
|
migrationBuilder.DropColumn(name: "end_date", table: "prime_schedules");
|
||||||
|
migrationBuilder.DropColumn(name: "workdays_only", table: "prime_schedules");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the generated `Down` body**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateOnly>(
|
||||||
|
name: "start_date", table: "prime_schedules",
|
||||||
|
type: "TEXT", nullable: false, defaultValue: new DateOnly(2000, 1, 1));
|
||||||
|
migrationBuilder.AddColumn<DateOnly>(
|
||||||
|
name: "end_date", table: "prime_schedules",
|
||||||
|
type: "TEXT", nullable: false, defaultValue: new DateOnly(2099, 12, 31));
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "workdays_only", table: "prime_schedules",
|
||||||
|
type: "INTEGER", nullable: false, defaultValue: true);
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"UPDATE prime_schedules SET workdays_only = CASE WHEN days_of_week = 127 THEN 0 ELSE 1 END;");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "days_of_week", table: "prime_schedules");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `using System;` at the top of the migration file if `DateOnly` defaults require it (the existing AddPrimeSchedules migration already imports `System`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the Data project**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/Migrations
|
||||||
|
git commit -m "feat(data): migrate prime schedules to days_of_week bitmask"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Worker DTO + NextDueCalculator (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the Worker DTO**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Worker.Prime;
|
||||||
|
|
||||||
|
public sealed record PrimeScheduleDto(
|
||||||
|
Guid Id,
|
||||||
|
int Days,
|
||||||
|
TimeSpan TimeOfDay,
|
||||||
|
bool Enabled,
|
||||||
|
DateTimeOffset? LastRunAt,
|
||||||
|
string? PromptOverride);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite the calculator tests**
|
||||||
|
|
||||||
|
Replace the entire body of `tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs`. Note: 2026-05-05 is a Tuesday; 2026-05-08 is a Friday; 2026-05-09/10 are Sat/Sun; 2026-05-11 is a Monday.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Worker.Prime;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Prime;
|
||||||
|
|
||||||
|
public class NextDueCalculatorTests
|
||||||
|
{
|
||||||
|
private static PrimeScheduleDto Schedule(
|
||||||
|
PrimeDays days, TimeSpan time,
|
||||||
|
bool enabled = true, DateTimeOffset? lastRun = null) =>
|
||||||
|
new(Guid.NewGuid(), (int)days, time, enabled, lastRun, null);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Disabled_Schedule_Returns_Null()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
|
||||||
|
var s = Schedule(PrimeDays.All, new(7, 0, 0), enabled: false);
|
||||||
|
Assert.Null(NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_Days_Selected_Returns_Null()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
|
||||||
|
var s = Schedule(PrimeDays.None, new(7, 0, 0));
|
||||||
|
Assert.Null(NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Future_Same_Day_Returns_Today_At_Target()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); // Tue
|
||||||
|
var s = Schedule(PrimeDays.All, new(7, 0, 0));
|
||||||
|
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
|
||||||
|
Assert.NotNull(r);
|
||||||
|
Assert.Equal(new DateTimeOffset(2026, 5, 5, 7, 0, 0, now.Offset), r!.At);
|
||||||
|
Assert.False(r.FireImmediately);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Within_CatchUp_Window_Fires_Immediately()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 5, 5, 7, 15, 0, TimeSpan.FromHours(2));
|
||||||
|
var s = Schedule(PrimeDays.All, new(7, 0, 0));
|
||||||
|
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
|
||||||
|
Assert.NotNull(r);
|
||||||
|
Assert.True(r!.FireImmediately);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)); // Tue
|
||||||
|
var s = Schedule(PrimeDays.All, new(7, 0, 0));
|
||||||
|
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
|
||||||
|
Assert.NotNull(r);
|
||||||
|
Assert.Equal(new DateOnly(2026, 5, 6), DateOnly.FromDateTime(r!.At.LocalDateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Weekdays_Only_Skips_Weekend()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2)); // Fri, past catch-up
|
||||||
|
var s = Schedule(PrimeDays.Weekdays, new(7, 0, 0));
|
||||||
|
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
|
||||||
|
Assert.NotNull(r);
|
||||||
|
Assert.Equal(DayOfWeek.Monday, r!.At.LocalDateTime.DayOfWeek);
|
||||||
|
Assert.Equal(new DateOnly(2026, 5, 11), DateOnly.FromDateTime(r.At.LocalDateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Single_Day_Schedule_Targets_That_Weekday()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 5, 5, 8, 0, 0, TimeSpan.FromHours(2)); // Tue, past catch-up
|
||||||
|
var s = Schedule(PrimeDays.Friday, new(7, 0, 0));
|
||||||
|
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
|
||||||
|
Assert.NotNull(r);
|
||||||
|
Assert.Equal(DayOfWeek.Friday, r!.At.LocalDateTime.DayOfWeek);
|
||||||
|
Assert.Equal(new DateOnly(2026, 5, 8), DateOnly.FromDateTime(r.At.LocalDateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Already_Fired_Today_Skips_To_Next_Eligible_Day()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
|
||||||
|
var lastRun = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2));
|
||||||
|
var s = Schedule(PrimeDays.All, new(7, 0, 0), lastRun: lastRun);
|
||||||
|
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
|
||||||
|
Assert.NotNull(r);
|
||||||
|
Assert.Equal(new DateOnly(2026, 5, 6), DateOnly.FromDateTime(r!.At.LocalDateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Multiple_Schedules_Returns_Earliest()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
|
||||||
|
var early = Schedule(PrimeDays.All, new(7, 0, 0));
|
||||||
|
var late = Schedule(PrimeDays.All, new(9, 0, 0));
|
||||||
|
var r = NextDueCalculator.Compute(new[] { late, early }, now, TimeSpan.FromMinutes(30));
|
||||||
|
Assert.NotNull(r);
|
||||||
|
Assert.Equal(early.Id, r!.Schedule.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter FullyQualifiedName~NextDueCalculatorTests`
|
||||||
|
Expected: FAIL — `PrimeScheduleDto` no longer has `StartDate`/`EndDate`/`workdaysOnly`, and the calculator still references them (compile errors).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rewrite the calculator**
|
||||||
|
|
||||||
|
Replace the entire body of `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Prime;
|
||||||
|
|
||||||
|
public sealed record NextDue(PrimeScheduleDto Schedule, DateTimeOffset At, bool FireImmediately);
|
||||||
|
|
||||||
|
public static class NextDueCalculator
|
||||||
|
{
|
||||||
|
public static NextDue? Compute(
|
||||||
|
IEnumerable<PrimeScheduleDto> schedules,
|
||||||
|
DateTimeOffset now,
|
||||||
|
TimeSpan catchUp)
|
||||||
|
{
|
||||||
|
NextDue? best = null;
|
||||||
|
foreach (var s in schedules)
|
||||||
|
{
|
||||||
|
if (!s.Enabled) continue;
|
||||||
|
var due = ComputeFor(s, now, catchUp);
|
||||||
|
if (due is null) continue;
|
||||||
|
if (best is null || due.At < best.At) best = due;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NextDue? ComputeFor(PrimeScheduleDto s, DateTimeOffset now, TimeSpan catchUp)
|
||||||
|
{
|
||||||
|
if ((PrimeDays)s.Days == PrimeDays.None) return null;
|
||||||
|
|
||||||
|
var todayLocal = DateOnly.FromDateTime(now.LocalDateTime);
|
||||||
|
var alreadyFiredToday = s.LastRunAt is { } last &&
|
||||||
|
DateOnly.FromDateTime(last.LocalDateTime) == todayLocal;
|
||||||
|
|
||||||
|
if (!alreadyFiredToday && IsEligibleDay(s, todayLocal))
|
||||||
|
{
|
||||||
|
var todayTarget = ToOffset(todayLocal, s.TimeOfDay, now.Offset);
|
||||||
|
if (todayTarget >= now)
|
||||||
|
return new NextDue(s, todayTarget, false);
|
||||||
|
if (now <= todayTarget + catchUp)
|
||||||
|
return new NextDue(s, now, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var d = todayLocal.AddDays(1);
|
||||||
|
for (int i = 0; i < 7; i++)
|
||||||
|
{
|
||||||
|
if (IsEligibleDay(s, d))
|
||||||
|
return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false);
|
||||||
|
d = d.AddDays(1);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEligibleDay(PrimeScheduleDto s, DateOnly d) =>
|
||||||
|
((PrimeDays)s.Days & ToFlag(d.DayOfWeek)) != PrimeDays.None;
|
||||||
|
|
||||||
|
private static PrimeDays ToFlag(DayOfWeek dow) => dow switch
|
||||||
|
{
|
||||||
|
DayOfWeek.Monday => PrimeDays.Monday,
|
||||||
|
DayOfWeek.Tuesday => PrimeDays.Tuesday,
|
||||||
|
DayOfWeek.Wednesday => PrimeDays.Wednesday,
|
||||||
|
DayOfWeek.Thursday => PrimeDays.Thursday,
|
||||||
|
DayOfWeek.Friday => PrimeDays.Friday,
|
||||||
|
DayOfWeek.Saturday => PrimeDays.Saturday,
|
||||||
|
DayOfWeek.Sunday => PrimeDays.Sunday,
|
||||||
|
_ => PrimeDays.None,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DateTimeOffset ToOffset(DateOnly day, TimeSpan time, TimeSpan offset) =>
|
||||||
|
new(day.Year, day.Month, day.Day, time.Hours, time.Minutes, time.Seconds, offset);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the calculator tests**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter FullyQualifiedName~NextDueCalculatorTests`
|
||||||
|
Expected: still FAILS to build — `PrimeScheduler.ToDto` and `WorkerHub` mappings reference removed fields. Proceed to Tasks 5–6, then re-run.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs src/ClaudeDo.Worker/Prime/NextDueCalculator.cs tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs
|
||||||
|
git commit -m "feat(worker): compute prime due-time from weekday bitmask"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: PrimeScheduler.ToDto + scheduler tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Prime/PrimeScheduler.cs:104-105`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the `ToDto` mapping**
|
||||||
|
|
||||||
|
Replace the `ToDto` method in `PrimeScheduler.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) =>
|
||||||
|
new(e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update scheduler test fixtures**
|
||||||
|
|
||||||
|
In `tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`, every `new PrimeScheduleEntity { ... }` initializer sets `StartDate`/`EndDate`/`WorkdaysOnly`. Replace those three lines in each of the three initializers (lines ~48-52, ~89-94, ~131-136) with a single `Days` assignment. Each initializer becomes:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Days = PrimeDays.All,
|
||||||
|
TimeOfDay = new TimeSpan(7, 0, 0),
|
||||||
|
Enabled = true,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `using ClaudeDo.Data.Models;` to the file's usings if not already present (it is, via line 1).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run scheduler + calculator tests**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~Prime"`
|
||||||
|
Expected: still build-fails until `WorkerHub` (Task 6) compiles. After Task 6, this command must PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Prime/PrimeScheduler.cs tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs
|
||||||
|
git commit -m "test(worker): adapt prime scheduler tests to weekday model"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: WorkerHub mapping + repository tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:488-518`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `ListPrimeSchedules`**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<List<PrimeScheduleDto>> ListPrimeSchedules()
|
||||||
|
{
|
||||||
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var rows = await new PrimeScheduleRepository(ctx).ListAsync();
|
||||||
|
return rows.Select(e => new PrimeScheduleDto(
|
||||||
|
e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `UpsertPrimeSchedule`**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto)
|
||||||
|
{
|
||||||
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var repo = new PrimeScheduleRepository(ctx);
|
||||||
|
var existing = await repo.GetAsync(dto.Id);
|
||||||
|
var entity = new ClaudeDo.Data.Models.PrimeScheduleEntity
|
||||||
|
{
|
||||||
|
Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id,
|
||||||
|
Days = (ClaudeDo.Data.Models.PrimeDays)dto.Days,
|
||||||
|
TimeOfDay = dto.TimeOfDay,
|
||||||
|
Enabled = dto.Enabled,
|
||||||
|
PromptOverride = dto.PromptOverride,
|
||||||
|
CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
|
||||||
|
LastRunAt = existing?.LastRunAt,
|
||||||
|
};
|
||||||
|
await repo.UpsertAsync(entity);
|
||||||
|
_primeSignal.Signal();
|
||||||
|
return new PrimeScheduleDto(entity.Id, (int)entity.Days, entity.TimeOfDay,
|
||||||
|
entity.Enabled, entity.LastRunAt, entity.PromptOverride);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`DeletePrimeSchedule` is unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update repository tests**
|
||||||
|
|
||||||
|
In `tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs`, replace each entity initializer's `StartDate`/`EndDate`/`WorkdaysOnly` lines with `Days = PrimeDays.Weekdays,` (drop them where only `StartDate`/`EndDate` appear). The three initializers become:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Upsert_Then_List_RoundTrips
|
||||||
|
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Days = PrimeDays.Weekdays,
|
||||||
|
TimeOfDay = new TimeSpan(7, 0, 0),
|
||||||
|
Enabled = true,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// UpdateLastRunAt_Persists
|
||||||
|
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Days = PrimeDays.Weekdays,
|
||||||
|
TimeOfDay = new TimeSpan(7, 0, 0),
|
||||||
|
Enabled = true,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Delete_Removes_Row
|
||||||
|
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Days = PrimeDays.All,
|
||||||
|
TimeOfDay = TimeSpan.Zero,
|
||||||
|
Enabled = true,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add an assertion in `Upsert_Then_List_RoundTrips` after the existing time assertion:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Assert.Equal(PrimeDays.Weekdays, rows[0].Days);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build worker + run all worker tests**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj && dotnet test tests/ClaudeDo.Worker.Tests`
|
||||||
|
Expected: PASS (all Prime + repository tests green).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs
|
||||||
|
git commit -m "feat(worker): map prime schedule weekday bitmask over the hub"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: UI DTO + ViewModels + tests (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the UI DTO**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs` (keep `PrimeFiredEvent` unchanged):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
public sealed record PrimeScheduleDto(
|
||||||
|
Guid Id,
|
||||||
|
int Days,
|
||||||
|
TimeSpan TimeOfDay,
|
||||||
|
bool Enabled,
|
||||||
|
DateTimeOffset? LastRunAt,
|
||||||
|
string? PromptOverride);
|
||||||
|
|
||||||
|
public sealed record PrimeFiredEvent(
|
||||||
|
Guid ScheduleId,
|
||||||
|
bool Success,
|
||||||
|
string Message,
|
||||||
|
DateTimeOffset FiredAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite the row VM**
|
||||||
|
|
||||||
|
Replace the body of `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||||
|
|
||||||
|
public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private const int Mon = 1, Tue = 2, Wed = 4, Thu = 8, Fri = 16, Sat = 32, Sun = 64;
|
||||||
|
|
||||||
|
public Guid Id { get; }
|
||||||
|
public bool IsExisting { get; }
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _enabled;
|
||||||
|
[ObservableProperty] private bool _monday;
|
||||||
|
[ObservableProperty] private bool _tuesday;
|
||||||
|
[ObservableProperty] private bool _wednesday;
|
||||||
|
[ObservableProperty] private bool _thursday;
|
||||||
|
[ObservableProperty] private bool _friday;
|
||||||
|
[ObservableProperty] private bool _saturday;
|
||||||
|
[ObservableProperty] private bool _sunday;
|
||||||
|
[ObservableProperty] private TimeSpan _timeOfDay;
|
||||||
|
[ObservableProperty] private DateTimeOffset? _lastRunAt;
|
||||||
|
|
||||||
|
public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—";
|
||||||
|
|
||||||
|
partial void OnLastRunAtChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastRunLabel));
|
||||||
|
|
||||||
|
public PrimeScheduleRowViewModel(PrimeScheduleDto dto, bool isExisting)
|
||||||
|
{
|
||||||
|
Id = dto.Id;
|
||||||
|
IsExisting = isExisting;
|
||||||
|
Enabled = dto.Enabled;
|
||||||
|
Monday = (dto.Days & Mon) != 0;
|
||||||
|
Tuesday = (dto.Days & Tue) != 0;
|
||||||
|
Wednesday = (dto.Days & Wed) != 0;
|
||||||
|
Thursday = (dto.Days & Thu) != 0;
|
||||||
|
Friday = (dto.Days & Fri) != 0;
|
||||||
|
Saturday = (dto.Days & Sat) != 0;
|
||||||
|
Sunday = (dto.Days & Sun) != 0;
|
||||||
|
TimeOfDay = dto.TimeOfDay;
|
||||||
|
LastRunAt = dto.LastRunAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int DaysMask()
|
||||||
|
{
|
||||||
|
int m = 0;
|
||||||
|
if (Monday) m |= Mon;
|
||||||
|
if (Tuesday) m |= Tue;
|
||||||
|
if (Wednesday) m |= Wed;
|
||||||
|
if (Thursday) m |= Thu;
|
||||||
|
if (Friday) m |= Fri;
|
||||||
|
if (Saturday) m |= Sat;
|
||||||
|
if (Sunday) m |= Sun;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PrimeScheduleDto ToDto() =>
|
||||||
|
new(Id, DaysMask(), TimeOfDay, Enabled, LastRunAt, null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the tab VM**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs`, replace `Validate` and `AddSchedule`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public string? Validate()
|
||||||
|
{
|
||||||
|
foreach (var r in Rows)
|
||||||
|
{
|
||||||
|
if (r.DaysMask() == 0)
|
||||||
|
return $"Schedule {r.TimeOfDay:hh\\:mm}: select at least one day.";
|
||||||
|
if (r.TimeOfDay < TimeSpan.Zero || r.TimeOfDay >= TimeSpan.FromDays(1))
|
||||||
|
return "Time must be between 00:00 and 23:59.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
private void AddSchedule()
|
||||||
|
{
|
||||||
|
var dto = new PrimeScheduleDto(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Days: 31, // Mon–Fri
|
||||||
|
TimeOfDay: new TimeSpan(7, 0, 0),
|
||||||
|
Enabled: true,
|
||||||
|
LastRunAt: null,
|
||||||
|
PromptOverride: null);
|
||||||
|
Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: false));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`LoadAsync`, `SaveAsync`, `RemoveSchedule`, `ApplyFiredEvent` are unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rewrite the tab VM tests**
|
||||||
|
|
||||||
|
Replace the body of `tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class PrimeClaudeTabViewModelTests
|
||||||
|
{
|
||||||
|
private sealed class FakeApi : IPrimeScheduleApi
|
||||||
|
{
|
||||||
|
public List<PrimeScheduleDto> Stored { get; } = new();
|
||||||
|
public List<PrimeScheduleDto> Upserts { get; } = new();
|
||||||
|
public List<Guid> Deletes { get; } = new();
|
||||||
|
public Task<List<PrimeScheduleDto>> ListAsync() => Task.FromResult(Stored.ToList());
|
||||||
|
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto)
|
||||||
|
{
|
||||||
|
Upserts.Add(dto);
|
||||||
|
return Task.FromResult<PrimeScheduleDto?>(dto);
|
||||||
|
}
|
||||||
|
public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time) =>
|
||||||
|
new(id, days, time, true, null, null);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Load_Populates_Rows()
|
||||||
|
{
|
||||||
|
var api = new FakeApi();
|
||||||
|
api.Stored.Add(Dto(Guid.NewGuid(), 31, new TimeSpan(7, 0, 0)));
|
||||||
|
var vm = new PrimeClaudeTabViewModel(api);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
Assert.Single(vm.Rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddSchedule_Appends_Row_With_Defaults()
|
||||||
|
{
|
||||||
|
var vm = new PrimeClaudeTabViewModel(new FakeApi());
|
||||||
|
vm.AddScheduleCommand.Execute(null);
|
||||||
|
Assert.Single(vm.Rows);
|
||||||
|
Assert.True(vm.Rows[0].Enabled);
|
||||||
|
Assert.True(vm.Rows[0].Monday);
|
||||||
|
Assert.True(vm.Rows[0].Friday);
|
||||||
|
Assert.False(vm.Rows[0].Saturday);
|
||||||
|
Assert.Equal(new TimeSpan(7, 0, 0), vm.Rows[0].TimeOfDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Row_Decomposes_And_Recomposes_Days()
|
||||||
|
{
|
||||||
|
var vm = new PrimeClaudeTabViewModel(new FakeApi());
|
||||||
|
vm.AddScheduleCommand.Execute(null);
|
||||||
|
var row = vm.Rows[0];
|
||||||
|
Assert.Equal(31, row.DaysMask());
|
||||||
|
row.Saturday = true;
|
||||||
|
Assert.Equal(63, row.DaysMask());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Save_Diffs_New_And_Removed_Rows()
|
||||||
|
{
|
||||||
|
var api = new FakeApi();
|
||||||
|
var keptId = Guid.NewGuid();
|
||||||
|
var deletedId = Guid.NewGuid();
|
||||||
|
api.Stored.Add(Dto(keptId, 31, new TimeSpan(7, 0, 0)));
|
||||||
|
api.Stored.Add(Dto(deletedId, 31, new TimeSpan(8, 0, 0)));
|
||||||
|
|
||||||
|
var vm = new PrimeClaudeTabViewModel(api);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
vm.RemoveScheduleCommand.Execute(vm.Rows.Single(r => r.Id == deletedId));
|
||||||
|
vm.AddScheduleCommand.Execute(null);
|
||||||
|
|
||||||
|
await vm.SaveAsync();
|
||||||
|
|
||||||
|
Assert.Contains(deletedId, api.Deletes);
|
||||||
|
Assert.Equal(2, api.Upserts.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Reports_No_Days_Selected()
|
||||||
|
{
|
||||||
|
var vm = new PrimeClaudeTabViewModel(new FakeApi());
|
||||||
|
vm.AddScheduleCommand.Execute(null);
|
||||||
|
var row = vm.Rows[0];
|
||||||
|
row.Monday = row.Tuesday = row.Wednesday = row.Thursday = row.Friday = false;
|
||||||
|
Assert.NotNull(vm.Validate());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Passes_With_One_Day()
|
||||||
|
{
|
||||||
|
var vm = new PrimeClaudeTabViewModel(new FakeApi());
|
||||||
|
vm.AddScheduleCommand.Execute(null);
|
||||||
|
Assert.Null(vm.Validate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run UI tests**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter FullyQualifiedName~PrimeClaudeTabViewModelTests`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs
|
||||||
|
git commit -m "feat(ui): drive prime schedule rows from weekday toggles"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: XAML — toggle-button row
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a `day-toggle` style class**
|
||||||
|
|
||||||
|
Append to `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (inside the root `<Styles>` element, alongside the other style selectors). Uses existing dynamic-resource tokens — no hardcoded colors:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Style Selector="ToggleButton.day-toggle">
|
||||||
|
<Setter Property="MinWidth" Value="34"/>
|
||||||
|
<Setter Property="Padding" Value="6,4"/>
|
||||||
|
<Setter Property="Margin" Value="0"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="CornerRadius" Value="4"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ToggleButton.day-toggle:checked /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
|
||||||
|
</Style>
|
||||||
|
```
|
||||||
|
|
||||||
|
If `AccentBrush` is not a defined token, use the brush the project uses for primary/selected affordances (check the `primary` button style in this file and reuse that brush). Final visual pass is the user's.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the Prime row template**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`, replace the `<Grid ...>` inside the Prime `DataTemplate` (currently columns `Auto,*,Auto,Auto,Auto,Auto` with the `ThemedDatePicker` and Mon–Fri checkbox) with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="8">
|
||||||
|
<CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||||
|
<ToggleButton Classes="day-toggle" Content="Mo" IsChecked="{Binding Monday, Mode=TwoWay}"/>
|
||||||
|
<ToggleButton Classes="day-toggle" Content="Tu" IsChecked="{Binding Tuesday, Mode=TwoWay}"/>
|
||||||
|
<ToggleButton Classes="day-toggle" Content="We" IsChecked="{Binding Wednesday, Mode=TwoWay}"/>
|
||||||
|
<ToggleButton Classes="day-toggle" Content="Th" IsChecked="{Binding Thursday, Mode=TwoWay}"/>
|
||||||
|
<ToggleButton Classes="day-toggle" Content="Fr" IsChecked="{Binding Friday, Mode=TwoWay}"/>
|
||||||
|
<ToggleButton Classes="day-toggle" Content="Sa" IsChecked="{Binding Saturday, Mode=TwoWay}"/>
|
||||||
|
<ToggleButton Classes="day-toggle" Content="Su" IsChecked="{Binding Sunday, Mode=TwoWay}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBox Grid.Column="2" Width="64"
|
||||||
|
Text="{Binding TimeOfDay, Mode=TwoWay, Converter={StaticResource TimeSpanToHhmm}}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
|
||||||
|
MinWidth="80"/>
|
||||||
|
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</Grid>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the explainer text**
|
||||||
|
|
||||||
|
Replace the intro `TextBlock` Text in the Prime tab (`SettingsModalView.axaml`):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
Text="Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately."/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Remove the now-unused range converter (only if unreferenced)**
|
||||||
|
|
||||||
|
The `DateOnlyToDateTime` resource on line 23 was used only by the range picker. Grep the file: if `DateOnlyToDateTime` has no other reference, remove the `<conv:DateOnlyToDateTimeConverter x:Key="DateOnlyToDateTime"/>` line. Keep `TimeSpanToHhmm` (still used).
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual UI check**
|
||||||
|
|
||||||
|
Start the worker, then the app. Open Settings → Prime Claude. Verify: a row shows 7 toggle buttons with Mon–Fri lit by default; toggling Sat/Sun persists after Save+reopen; clearing all days shows the validation error on Save. (UI correctness can only be confirmed in the running app — state so explicitly if it cannot be run.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
|
||||||
|
git commit -m "feat(ui): replace prime date range with weekday toggle buttons"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/CLAUDE.md`
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the Data CLAUDE.md**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Data/CLAUDE.md`, the Models section has no PrimeSchedule line today; add one under Models, and confirm the `prime_schedules` table mention in the Schema section stays accurate:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the root CLAUDE.md if Prime is described**
|
||||||
|
|
||||||
|
Grep `CLAUDE.md` for "Prime"; if there is a Prime description mentioning a date range, update it to "recurring weekday schedule". If there is no such line, make no change.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Full test sweep**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests && dotnet test tests/ClaudeDo.Ui.Tests`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/CLAUDE.md CLAUDE.md
|
||||||
|
git commit -m "docs: describe recurring-weekday Prime schedule"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage:** data model (T1), scheduling logic (T4), UI toggles (T7–T8), migration+backfill (T3), both DTOs (T4/T7), tests (T4–T7), out-of-scope items excluded. ✓
|
||||||
|
- **Type consistency:** entity `PrimeDays Days`; both DTOs `int Days`; hub/scheduler cast `(int)`/`(PrimeDays)` at boundaries; calculator casts `(PrimeDays)s.Days`; row VM exposes 7 bools + `DaysMask()`. ✓
|
||||||
|
- **Build ripple:** a single type change breaks several projects at once, so some intermediate steps note expected build failures; the gating green builds are T3 Step 4 (Data), T6 Step 4 (Worker + tests), T8 Step 4 (App). ✓
|
||||||
|
```
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
# Daily Prep — Live Output View + Clear Day — 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:** Stream the daily-prep run's output into a live, human-readable view (a new mode in the Details island), and add a "Clear Day" button that empties MyDay.
|
||||||
|
|
||||||
|
**Architecture:** The worker broadcasts `PrepStarted/PrepLine/PrepFinished` over SignalR (mirroring `TaskStarted/TaskMessage/TaskFinished`). `PrimeRunner` forwards each Claude stdout line instead of discarding it. The UI `WorkerClient` re-raises these as events; `DetailsIslandViewModel` gains a `PrepLog` + `IsPrepMode` panel rendered with the existing terminal renderer. A `ClearMyDay` hub method bulk-clears `IsMyDay`. MyDay header gets "Vorbereitungs-Log" and "Tag leeren" buttons.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, ASP.NET Core SignalR, EF Core (SQLite), Avalonia + CommunityToolkit.Mvvm, xUnit.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-03-daily-prep-live-view-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & test commands
|
||||||
|
|
||||||
|
`.slnx` needs .NET 9; build/test individual csproj with `-c Release` (a running Worker may lock Debug).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
UI cannot be GUI-smoke-tested headlessly — note that explicitly where it applies; the human verifies visuals.
|
||||||
|
|
||||||
|
## Reference anchors (verify before editing — line numbers drift)
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs` — currently only `PrimeFiredAsync`.
|
||||||
|
- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs:13-57` — broadcast methods; `PrimeFired` at ~52-56.
|
||||||
|
- `src/ClaudeDo.Worker/Prime/PrimeRunner.cs:31-79` — `FireAsync`; discard lambda at ~55-60; ctor at ~19-29.
|
||||||
|
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs:542-549` — `RunDailyPrepNow` (uses `_broadcaster`); DailyNote CRUD at 559-583 (shows the db-context pattern this hub uses).
|
||||||
|
- `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:19` — `TaskMessageEvent`; `:55` — `RunDailyPrepNowAsync`.
|
||||||
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs:99-122` — `TaskStarted/Finished/Message` hub.On; `:170-173` — `PrimeFired` hub.On (the pattern to copy).
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — `IsNotesMode` ~56, `Log` ~193, ctor/subscriptions ~272-337, `OnTaskMessage` ~339-363 (stdout→`StreamLineFormatter`→`Log`), `ShowNotes` ~478-483.
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml:131-302` — body grid; task panel `IsVisible="{Binding !IsNotesMode}"`, notes panel `IsVisible="{Binding IsNotesMode}"`; `SessionTerminalView` embedded ~295.
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml:54-75` — `ItemsControl ItemsSource="{Binding Log}"` + the `LogLineViewModel` item template to reuse.
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — `NotesRequested` ~29, `OpenNotesCommand`+`PrepareDayCommand` ~33-45, `ShowNotesRow`/`IsMyDayList` ~65-66, both set in `LoadForList` ~212-213.
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml:69-84` — Notes + PrepareDay buttons (styling to copy).
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs:199-201` — island event wiring; `:225` — `PrimeFired` subscription.
|
||||||
|
- Fakes to keep in sync: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (`FakeWorkerClient`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Worker — prep output broadcast + streaming
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test.** Extend `PrimeRunnerTests` with a fake `IPrimeBroadcaster` that records calls. The fake `IClaudeProcess` should invoke `onStdoutLine` with two sample lines and return `RunResult { ExitCode = 0, ResultMarkdown = "ok" }`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task FireAsync_streams_started_lines_and_finished()
|
||||||
|
{
|
||||||
|
var broadcaster = new RecordingPrimeBroadcaster();
|
||||||
|
var claude = new FakeClaudeProcess(emitLines: new[] { "{\"a\":1}", "{\"b\":2}" }, exitCode: 0, result: "ok");
|
||||||
|
var runner = NewRunner(claude, broadcaster); // build with temp-sqlite dbFactory + fake clock + logger + broadcaster
|
||||||
|
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
|
||||||
|
|
||||||
|
var outcome = await runner.FireAsync(schedule, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(outcome.Success);
|
||||||
|
Assert.Equal(1, broadcaster.StartedCount);
|
||||||
|
Assert.Equal(new[] { "{\"a\":1}", "{\"b\":2}" }, broadcaster.Lines);
|
||||||
|
Assert.Single(broadcaster.FinishedResults);
|
||||||
|
Assert.True(broadcaster.FinishedResults[0]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`RecordingPrimeBroadcaster` implements `IPrimeBroadcaster`: `StartedCount`, `List<string> Lines`, `List<bool> FinishedResults`, and a no-op `PrimeFiredAsync`. If the existing `FakeClaudeProcess` cannot emit lines, add an optional `emitLines` parameter that loops `await onStdoutLine(line)` before returning.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL** (interface methods + ctor param missing).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter PrimeRunner
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend `IPrimeBroadcaster`:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface IPrimeBroadcaster
|
||||||
|
{
|
||||||
|
Task PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt);
|
||||||
|
Task PrepStartedAsync();
|
||||||
|
Task PrepLineAsync(string line);
|
||||||
|
Task PrepFinishedAsync(bool success);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Keep the existing `PrimeFiredAsync` signature exactly as it is in the current file.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement in `HubBroadcaster`** (add next to `PrimeFired`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task PrepStarted() => _hub.Clients.All.SendAsync("PrepStarted");
|
||||||
|
public Task PrepLine(string line) => _hub.Clients.All.SendAsync("PrepLine", line);
|
||||||
|
public Task PrepFinished(bool success) => _hub.Clients.All.SendAsync("PrepFinished", success);
|
||||||
|
|
||||||
|
Task IPrimeBroadcaster.PrepStartedAsync() => PrepStarted();
|
||||||
|
Task IPrimeBroadcaster.PrepLineAsync(string line) => PrepLine(line);
|
||||||
|
Task IPrimeBroadcaster.PrepFinishedAsync(bool success) => PrepFinished(success);
|
||||||
|
```
|
||||||
|
|
||||||
|
(Match the existing explicit-interface style used for `PrimeFiredAsync`.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Wire `PrimeRunner`.** Add `IPrimeBroadcaster _broadcaster` as a ctor param (and field). Rewrite the body of `FireAsync` after the gate check to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (!await _gate.WaitAsync(0, ct))
|
||||||
|
return new PrimeRunOutcome(false, "Daily prep already running");
|
||||||
|
|
||||||
|
var success = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _broadcaster.PrepStartedAsync();
|
||||||
|
|
||||||
|
var cwd = Paths.AppDataRoot();
|
||||||
|
Directory.CreateDirectory(cwd);
|
||||||
|
|
||||||
|
int maxTasks;
|
||||||
|
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
|
||||||
|
{
|
||||||
|
var settings = await new AppSettingsRepository(dbCtx).GetAsync(ct);
|
||||||
|
maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime);
|
||||||
|
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks, today);
|
||||||
|
var args = DailyPrepPrompt.BuildArgs(MaxTurns);
|
||||||
|
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeoutCts.CancelAfter(FireTimeout);
|
||||||
|
|
||||||
|
var result = await _claude.RunAsync(
|
||||||
|
arguments: args,
|
||||||
|
prompt: prompt,
|
||||||
|
workingDirectory: cwd,
|
||||||
|
onStdoutLine: line => _broadcaster.PrepLineAsync(line),
|
||||||
|
ct: timeoutCts.Token);
|
||||||
|
|
||||||
|
success = result.IsSuccess;
|
||||||
|
return success
|
||||||
|
? new PrimeRunOutcome(true, "Daily prep complete")
|
||||||
|
: new PrimeRunOutcome(false, $"exit code {result.ExitCode}");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Daily prep run failed");
|
||||||
|
return new PrimeRunOutcome(false, ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _broadcaster.PrepFinishedAsync(success);
|
||||||
|
_gate.Release();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
DI is unchanged: `AddSingleton<IPrimeRunner, PrimeRunner>()` resolves `IPrimeBroadcaster` (registered as `sp => sp.GetRequiredService<HubBroadcaster>()`).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update existing `PrimeRunnerTests` ctor calls** to pass the recording broadcaster; build + run.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter PrimeRunner
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Prime src/ClaudeDo.Worker/Hub/HubBroadcaster.cs tests/ClaudeDo.Worker.Tests/Prime
|
||||||
|
git commit -m "feat(daily-prep): stream prep output via PrepStarted/PrepLine/PrepFinished"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Worker — `ClearMyDay` hub method
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
- Test: a new/existing hub test under `tests/ClaudeDo.Worker.Tests/Hub/` (mirror an existing hub test that seeds a real SQLite db and constructs `WorkerHub`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test.** Seed three tasks: two with `IsMyDay=true` (one Idle, one Done), one with `IsMyDay=false`. Construct `WorkerHub` the way existing hub tests do (the same `null!` argument list, plus a recording `HubBroadcaster`/clients). Call `ClearMyDay()`; assert both MyDay rows are now `false`, the third is untouched, and the returned count is 2.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task ClearMyDay_clears_all_isMyDay_tasks()
|
||||||
|
{
|
||||||
|
// seed via the test's db helper ...
|
||||||
|
var hub = NewHub(/* ... */);
|
||||||
|
var cleared = await hub.ClearMyDay();
|
||||||
|
|
||||||
|
Assert.Equal(2, cleared);
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
Assert.False(await ctx.Tasks.AnyAsync(t => t.IsMyDay));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the method** to `WorkerHub` (use the same db-context acquisition the neighbouring hub methods use — e.g. `_dbFactory`/repository field name found in the file — and the existing `_broadcaster` field):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<int> ClearMyDay()
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var ids = await ctx.Tasks.Where(t => t.IsMyDay).Select(t => t.Id).ToListAsync();
|
||||||
|
if (ids.Count == 0) return 0;
|
||||||
|
|
||||||
|
await ctx.Tasks.Where(t => t.IsMyDay)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.IsMyDay, false));
|
||||||
|
|
||||||
|
foreach (var id in ids)
|
||||||
|
await _broadcaster.TaskUpdated(id);
|
||||||
|
|
||||||
|
return ids.Count;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `WorkerHub` does not already have an `IDbContextFactory<ClaudeDoDbContext>` field, use whatever data-access dependency the other hub methods use (read the file). Do NOT add a new ctor param unless unavoidable (it would break hub-test fakes — if you must, update all `new WorkerHub(...)` call sites).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run — expect PASS.** Build Worker.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Hub
|
||||||
|
git commit -m "feat(daily-prep): add ClearMyDay hub method"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: UI — WorkerClient prep events + ClearMyDayAsync
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
- Modify fakes: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (FakeWorkerClient)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Declare on `IWorkerClient`** (near `TaskMessageEvent` / `RunDailyPrepNowAsync`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
event Action? PrepStartedEvent;
|
||||||
|
event Action<string>? PrepLineEvent;
|
||||||
|
event Action<bool>? PrepFinishedEvent;
|
||||||
|
Task ClearMyDayAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement in `WorkerClient`.** Add the events; register hub callbacks mirroring the `PrimeFired` registration (~line 170):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public event Action? PrepStartedEvent;
|
||||||
|
public event Action<string>? PrepLineEvent;
|
||||||
|
public event Action<bool>? PrepFinishedEvent;
|
||||||
|
|
||||||
|
// in the hub-wiring section:
|
||||||
|
_hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke()));
|
||||||
|
_hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line)));
|
||||||
|
_hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok)));
|
||||||
|
|
||||||
|
public Task ClearMyDayAsync() => _connection.InvokeAsync("ClearMyDay");
|
||||||
|
```
|
||||||
|
|
||||||
|
(Use the exact connection field name and async-call style of neighbouring methods like `RunDailyPrepNowAsync` / `GenerateWeekReport`. `ClearMyDay` returns `int` on the hub; invoking it as a void `InvokeAsync("ClearMyDay")` is fine, or `InvokeAsync<int>` if you want the count.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the fakes.** Add the three events (as `public event …` auto-implemented) and `ClearMyDayAsync() => Task.CompletedTask` to both `StubWorkerClient` and `FakeWorkerClient`. For the ClearDay command test (Task 5), give `StubWorkerClient` a `ClearMyDayCalls` counter incremented in `ClearMyDayAsync`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build App + both test projects; fix any remaining fake gaps.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Services tests
|
||||||
|
git commit -m "feat(daily-prep): expose prep stream events and ClearMyDay on the UI worker client"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: UI — Details island prep mode + live log
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/...DetailsIslandViewModel...` (mirror existing Details VM tests; if none, add a small test file)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test.** Construct `DetailsIslandViewModel` with a `StubWorkerClient` (mirror existing construction). Then:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void PrepLine_event_appends_to_PrepLog()
|
||||||
|
{
|
||||||
|
var stub = new StubWorkerClient();
|
||||||
|
var vm = NewDetailsVm(stub);
|
||||||
|
|
||||||
|
stub.RaisePrepLine("{\"type\":\"assistant\",\"text\":\"hi\"}"); // helper that invokes PrepLineEvent
|
||||||
|
Assert.NotEmpty(vm.PrepLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShowPrep_sets_prep_mode_and_clears_notes_mode()
|
||||||
|
{
|
||||||
|
var vm = NewDetailsVm(new StubWorkerClient());
|
||||||
|
vm.ShowPrep();
|
||||||
|
Assert.True(vm.IsPrepMode);
|
||||||
|
Assert.False(vm.IsNotesMode);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `RaisePrepStarted/RaisePrepLine/RaisePrepFinished` helpers to `StubWorkerClient` that invoke the corresponding events.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement in `DetailsIslandViewModel`:**
|
||||||
|
- Add `[ObservableProperty] private bool _isPrepMode;` and `[ObservableProperty] private bool _isPrepRunning;`.
|
||||||
|
- Add `public ObservableCollection<LogLineViewModel> PrepLog { get; } = new();`.
|
||||||
|
- In the ctor, subscribe: `_worker.PrepStartedEvent += OnPrepStarted; _worker.PrepLineEvent += OnPrepLine; _worker.PrepFinishedEvent += OnPrepFinished;` (guard with the same `_worker is not null` pattern used for other events).
|
||||||
|
- Handlers:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void OnPrepStarted()
|
||||||
|
{
|
||||||
|
PrepLog.Clear();
|
||||||
|
IsPrepRunning = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPrepLine(string line) => AppendStdoutLine(PrepLog, line);
|
||||||
|
|
||||||
|
private void OnPrepFinished(bool success) => IsPrepRunning = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Factor the stdout-formatting currently inside `OnTaskMessage` into a reusable
|
||||||
|
`private void AppendStdoutLine(ObservableCollection<LogLineViewModel> target, string line)`
|
||||||
|
that runs the line through `StreamLineFormatter` and appends `LogLineViewModel`(s).
|
||||||
|
Have `OnTaskMessage`'s stdout branch call `AppendStdoutLine(Log, strippedLine)` so both
|
||||||
|
paths share one implementation. (Events arrive already on the UI thread via
|
||||||
|
`Dispatcher.UIThread.Post` in `WorkerClient`, so direct collection mutation is correct.)
|
||||||
|
- Add `public void ShowPrep()` mirroring `ShowNotes()`: call `Bind(null)`, set
|
||||||
|
`IsNotesMode = false`, `IsPrepMode = true`.
|
||||||
|
- In `ShowNotes()` add `IsPrepMode = false`. In `Bind(...)` reset both `IsNotesMode` and
|
||||||
|
`IsPrepMode` to false (find where `IsNotesMode` is reset; add `IsPrepMode` beside it).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `DetailsIslandView.axaml`.**
|
||||||
|
- Change the task-details panel visibility from `IsVisible="{Binding !IsNotesMode}"` to a
|
||||||
|
converter-free multi-condition. Avalonia lacks `&&` in bindings, so add a computed
|
||||||
|
property `public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode;` to the VM
|
||||||
|
(raise its change notification from the `OnIsNotesModeChanged`/`OnIsPrepModeChanged`
|
||||||
|
partial methods generated by `[ObservableProperty]`) and bind the task panel to
|
||||||
|
`IsVisible="{Binding IsTaskDetailVisible}"`.
|
||||||
|
- Add a third panel after the notes panel:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Panel IsVisible="{Binding IsPrepMode}">
|
||||||
|
<DockPanel>
|
||||||
|
<TextBlock DockPanel.Dock="Top" Margin="16,12"
|
||||||
|
Text="{loc:Tr details.prepTitle}" Classes="h2"/>
|
||||||
|
<ScrollViewer>
|
||||||
|
<ItemsControl ItemsSource="{Binding PrepLog}"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</Panel>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ItemsControl` reuses the implicit `LogLineViewModel` `DataTemplate` that
|
||||||
|
`SessionTerminalView` relies on. If that template is defined locally inside
|
||||||
|
`SessionTerminalView.axaml` (not in a shared resource), either move it to a shared
|
||||||
|
`ResourceDictionary` (e.g. App resources) and reference it from both, or set the
|
||||||
|
`ItemsControl.ItemTemplate` to a copy of that template. Prefer sharing over copying.
|
||||||
|
Add `details.prepTitle` ("Daily prep" / "Tagesvorbereitung") to both locale json files.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run UI tests — expect PASS; build App.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui src/ClaudeDo.Localization tests/ClaudeDo.Ui.Tests
|
||||||
|
git commit -m "feat(daily-prep): add live prep-output mode to the Details island"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: UI — MyDay buttons + shell wiring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/UiVm/...` or `tests/ClaudeDo.Ui.Tests/...` (TasksIslandViewModel)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests.**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task ClearDayCommand_calls_worker()
|
||||||
|
{
|
||||||
|
var stub = new StubWorkerClient();
|
||||||
|
var vm = NewTasksVm(stub);
|
||||||
|
await vm.ClearDayCommand.ExecuteAsync(null);
|
||||||
|
Assert.Equal(1, stub.ClearMyDayCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PrepareDayCommand_raises_PrepRequested()
|
||||||
|
{
|
||||||
|
var vm = NewTasksVm(new StubWorkerClient());
|
||||||
|
var raised = false;
|
||||||
|
vm.PrepRequested += () => raised = true;
|
||||||
|
await vm.PrepareDayCommand.ExecuteAsync(null);
|
||||||
|
Assert.True(raised);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement in `TasksIslandViewModel`:**
|
||||||
|
- Add `public event Action? PrepRequested;` next to `NotesRequested`.
|
||||||
|
- In `PrepareDayAsync` (the existing `[RelayCommand]`), raise `PrepRequested?.Invoke();`
|
||||||
|
in addition to the existing `RunDailyPrepNowAsync()` call.
|
||||||
|
- Add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
private void ShowPrepLog() => PrepRequested?.Invoke();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ClearDayAsync()
|
||||||
|
{
|
||||||
|
if (_worker is null) return;
|
||||||
|
try { await _worker.ClearMyDayAsync(); }
|
||||||
|
catch { /* worker offline; broadcast will reconcile on return */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the two buttons** to the MyDay header in `TasksIslandView.axaml`,
|
||||||
|
immediately after the existing "Prepare day" button (~line 84), copying its styling
|
||||||
|
(`DockPanel.Dock="Top" Classes="btn" HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Left" Margin="16,0,16,8" IsVisible="{Binding IsMyDayList}"`):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Button DockPanel.Dock="Top" Classes="btn" HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Left" Margin="16,0,16,8"
|
||||||
|
IsVisible="{Binding IsMyDayList}"
|
||||||
|
Command="{Binding ShowPrepLogCommand}"
|
||||||
|
Content="{loc:Tr tasks.prepLog}"/>
|
||||||
|
<Button DockPanel.Dock="Top" Classes="btn" HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Left" Margin="16,0,16,8"
|
||||||
|
IsVisible="{Binding IsMyDayList}"
|
||||||
|
Command="{Binding ClearDayCommand}"
|
||||||
|
Content="{loc:Tr tasks.clearDay}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `tasks.prepLog` (en "Prep log" / de "Vorbereitungs-Log") and `tasks.clearDay`
|
||||||
|
(en "Clear day" / de "Tag leeren") to both locale json files.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Wire the shell.** In `IslandsShellViewModel` where `Tasks.NotesRequested`
|
||||||
|
is wired (~line 201), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Tasks.PrepRequested += () => Details.ShowPrep();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run tests + build App.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Manual smoke (human, not headless):** start Worker + App, open MyDay, click
|
||||||
|
"Tag vorbereiten" → Details island opens in prep mode and streams readable lines; click
|
||||||
|
"Tag leeren" → MyDay empties; after a scheduled run, "Vorbereitungs-Log" opens the filled
|
||||||
|
log. Confirm the three buttons only appear on MyDay.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui src/ClaudeDo.Localization tests
|
||||||
|
git commit -m "feat(daily-prep): add Prep-log and Clear-day buttons to MyDay header"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
- [ ] Build Worker + App (Release).
|
||||||
|
- [ ] `dotnet test` Worker.Tests, Ui.Tests, Localization.Tests — all green.
|
||||||
|
- [ ] Manual: prep streams live into the Details island (manual opens it; scheduled fills it silently, opened via the button); Clear Day empties MyDay immediately.
|
||||||
|
|
||||||
|
## Notes / risks
|
||||||
|
|
||||||
|
- Mode flags `IsNotesMode` / `IsPrepMode` are mutually exclusive; the task-details panel
|
||||||
|
uses the computed `IsTaskDetailVisible`. Verify all three modes switch cleanly.
|
||||||
|
- Reusing the `LogLineViewModel` template: prefer promoting it to a shared resource over
|
||||||
|
copying, to avoid drift between the session terminal and the prep log.
|
||||||
|
- `ClearMyDay` broadcasts one `TaskUpdated` per affected id; MyDay is small (capped), so
|
||||||
|
this is fine.
|
||||||
|
- Keep `PrimeRunner`'s "already running" early-return emitting no prep events.
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
# Daily Prep ("Prime Claude") 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:** Turn the Prime Time warm-up into a daily preparation where Claude reads open tasks and moves an effort-aware, capped subset into MyDay, triggered by the Prime schedule and a manual button.
|
||||||
|
|
||||||
|
**Architecture:** Agentic. Two new tools on the always-on `ExternalMcpService` (`get_daily_prep_candidates`, `set_my_day` with a server-side cap-guard). The existing `PrimeRunner` is rewritten to launch a headless `claude -p` run with a fixed parameterized prompt and `--allowedTools` for those two tools, relying on the already-registered `claudedo` MCP (no separate `--mcp-config`). A new `DailyPrepMaxTasks` app setting drives the cap. A manual hub method reuses the same runner with a single-flight guard.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, ASP.NET Core, EF Core (SQLite), SignalR, ModelContextProtocol, Avalonia (CommunityToolkit.Mvvm), xUnit.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-03-daily-prep-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deviation from spec (deliberate, to minimize churn)
|
||||||
|
|
||||||
|
The spec proposed renaming `IPrimeRunner`/`PrimeRunner`/`PrimeScheduler` → `DailyPrep*`. **We keep the existing names and the `FireAsync(PrimeScheduleDto, ct)` signature** and only rewrite the runner body. This avoids touching the scheduler, DI registration, `IPrimeBroadcaster`, and the existing Prime tests for a pure rename. The per-schedule `PromptOverride` field becomes unused by the runner (left in the DB/UI untouched).
|
||||||
|
|
||||||
|
## Build & test commands (this repo)
|
||||||
|
|
||||||
|
`.slnx` needs .NET 9; on .NET 8 build/test individual projects. Use `-c Release` if a running Worker locks `Debug`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests use **real SQLite + real git** (project convention). Mirror the setup already present in the test file you are extending.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Create**
|
||||||
|
- `src/ClaudeDo.Data/Migrations/<timestamp>_DailyPrepMaxTasks.cs` (+ Designer, via `dotnet ef`)
|
||||||
|
- `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` — pure prompt + args builder (easy to unit-test)
|
||||||
|
|
||||||
|
**Modify**
|
||||||
|
- `src/ClaudeDo.Data/Models/AppSettingsEntity.cs` — add `DailyPrepMaxTasks`
|
||||||
|
- `src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs` — map column
|
||||||
|
- `src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs` — persist field in `UpdateAsync`
|
||||||
|
- `src/ClaudeDo.Worker/External/ExternalMcpService.cs` — add 2 tools + DTOs
|
||||||
|
- `src/ClaudeDo.Worker/Prime/PrimeRunner.cs` — rewrite body to daily prep + single-flight
|
||||||
|
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add `DailyPrepMaxTasks` to AppSettings DTO + `RunDailyPrepNow`
|
||||||
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — mirror `DailyPrepMaxTasks` in the UI AppSettings DTO + add `RunDailyPrepNow` call
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs` (+ its view) — numeric editor for `DailyPrepMaxTasks`
|
||||||
|
- MyDay list header view + its ViewModel — "Tag vorbereiten" button + command
|
||||||
|
|
||||||
|
**Test**
|
||||||
|
- `tests/ClaudeDo.Data.Tests/...AppSettings...` — new field persists / default 5
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` — candidate filter + set_my_day + cap-guard
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs` — prompt/args content
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs` (if present) — single-flight + success/failure via `IClaudeProcess` fake
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `DailyPrepMaxTasks` app setting
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/Models/AppSettingsEntity.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs`
|
||||||
|
- Create (via `dotnet ef`): `src/ClaudeDo.Data/Migrations/<timestamp>_DailyPrepMaxTasks.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Data.Tests` (extend existing AppSettings repository test, or add `AppSettingsRepositoryTests.cs`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
In a Data.Tests file (mirror the existing repo test harness that opens a real SQLite `ClaudeDoDbContext`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task DailyPrepMaxTasks_defaults_to_5_and_persists()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext(); // existing helper that migrates a temp sqlite db
|
||||||
|
var repo = new AppSettingsRepository(ctx);
|
||||||
|
|
||||||
|
var initial = await repo.GetAsync();
|
||||||
|
Assert.Equal(5, initial.DailyPrepMaxTasks);
|
||||||
|
|
||||||
|
initial.DailyPrepMaxTasks = 8;
|
||||||
|
await repo.UpdateAsync(initial);
|
||||||
|
|
||||||
|
var reloaded = await repo.GetAsync();
|
||||||
|
Assert.Equal(8, reloaded.DailyPrepMaxTasks);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it — expect FAIL** (`AppSettingsEntity` has no `DailyPrepMaxTasks`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter DailyPrepMaxTasks_defaults_to_5_and_persists
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the property** to `AppSettingsEntity.cs` after `StandupWeekday`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Max number of open tasks the daily prep ("Prime Claude") may place in MyDay.
|
||||||
|
public int DailyPrepMaxTasks { get; set; } = 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Map the column** in `AppSettingsEntityConfiguration.cs`, after the `StandupWeekday` mapping (before `builder.HasData(...)`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Property(s => s.DailyPrepMaxTasks)
|
||||||
|
.HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Persist it** in `AppSettingsRepository.UpdateAsync`, after the `StandupWeekday` assignment:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Generate the migration** (regenerates the model snapshot — do NOT hand-edit the snapshot):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet ef migrations add DailyPrepMaxTasks \
|
||||||
|
-p src/ClaudeDo.Data/ClaudeDo.Data.csproj \
|
||||||
|
-s src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the generated `Up` contains an `AddColumn<int>("daily_prep_max_tasks", ... defaultValue: 5)` and an `UpdateData` setting the singleton row's `daily_prep_max_tasks` to 5. If `dotnet ef` is unavailable, hand-write the migration mirroring `20260603072822_WeeklyReport.cs` **and** add the matching `Property<int>("DailyPrepMaxTasks").HasColumnName("daily_prep_max_tasks")` line to `ClaudeDoDbContextModelSnapshot.cs` under the `AppSettingsEntity` builder.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run the test — expect PASS.**
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data tests/ClaudeDo.Data.Tests
|
||||||
|
git commit -m "feat(daily-prep): add DailyPrepMaxTasks app setting"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `get_daily_prep_candidates` MCP tool
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||||
|
|
||||||
|
Read `ExternalMcpServiceTests.cs` first and reuse its existing harness (how it builds an `ExternalMcpService` with a real SQLite context, `ListRepository`, `TaskRepository`, fake `HubBroadcaster`, etc.). The new tool reads **all** lists/tasks itself via the injected `_dbFactory`, so it needs no new constructor args.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test.** Seed: a list with `WorkingDir = @"D:\work\repo"` holding two `Idle` tasks (one blocked, one not) and one `Done` task; a second list with `WorkingDir = @"C:\Private\secret"` holding one `Idle` task; a third list with `WorkingDir = null` holding one `Idle` task; and one `Idle` task with `IsMyDay = true` in the first list. Set `AppSettings.ReportExcludedPaths = "[\"C:\\\\Private\"]"`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDailyPrepCandidates_filters_by_status_block_and_excluded_repo()
|
||||||
|
{
|
||||||
|
// ... seed as described, using the file's existing seed helpers ...
|
||||||
|
var svc = NewService();
|
||||||
|
|
||||||
|
var result = await svc.GetDailyPrepCandidates(CancellationToken.None);
|
||||||
|
|
||||||
|
// Only the non-blocked, Idle, non-MyDay task in the non-excluded repo is a candidate.
|
||||||
|
Assert.Single(result.Candidates);
|
||||||
|
Assert.Equal("idle-unblocked", result.Candidates[0].Id);
|
||||||
|
// The Idle MyDay task is reported separately, not as a candidate.
|
||||||
|
Assert.Single(result.CurrentMyDay);
|
||||||
|
Assert.Equal(1, result.MaxTasks > 0 ? 1 : 1); // MaxTasks comes from AppSettings (default 5)
|
||||||
|
Assert.Equal(5, result.MaxTasks);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it — expect FAIL** (method missing).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the DTOs** near the other record declarations at the top of `ExternalMcpService.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record DailyPrepCandidateDto(
|
||||||
|
string Id, string ListId, string ListName, string Title, string? Description,
|
||||||
|
bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public sealed record DailyPrepDataDto(
|
||||||
|
int MaxTasks,
|
||||||
|
IReadOnlyList<DailyPrepCandidateDto> Candidates,
|
||||||
|
IReadOnlyList<DailyPrepCandidateDto> CurrentMyDay);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the tool method** to the `ExternalMcpService` class body:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Daily prep: returns the open tasks eligible for today's MyDay selection. " +
|
||||||
|
"candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " +
|
||||||
|
"currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " +
|
||||||
|
"maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")]
|
||||||
|
public async Task<DailyPrepDataDto> GetDailyPrepCandidates(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
|
||||||
|
var excludes = DailyPrepFilter.ParseExcludes(settings.ReportExcludedPaths);
|
||||||
|
var maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
|
||||||
|
|
||||||
|
var idle = await ctx.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(t => t.List)
|
||||||
|
.Where(t => t.Status == TaskStatus.Idle)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var currentMyDay = idle
|
||||||
|
.Where(t => t.IsMyDay)
|
||||||
|
.OrderBy(t => t.SortOrder)
|
||||||
|
.Select(ToCandidate)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var candidates = idle
|
||||||
|
.Where(t => !t.IsMyDay
|
||||||
|
&& t.BlockedByTaskId == null
|
||||||
|
&& DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
|
||||||
|
.OrderBy(t => t.CreatedAt)
|
||||||
|
.Select(ToCandidate)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new DailyPrepDataDto(maxTasks, candidates, currentMyDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new(
|
||||||
|
t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description,
|
||||||
|
t.IsStarred, t.ScheduledFor, t.CreatedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the filter helper** as a small static class at the bottom of `ExternalMcpService.cs` (single-consumer helper lives beside its consumer, per repo convention):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal static class DailyPrepFilter
|
||||||
|
{
|
||||||
|
public static string[] ParseExcludes(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
|
||||||
|
return list is null ? [] : list.Select(Normalize).Where(p => p.Length > 0).ToArray();
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsIncludedRepo(string? workingDir, string[] excludes)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(workingDir)) return false; // not a repo → excluded
|
||||||
|
var norm = Normalize(workingDir);
|
||||||
|
return !excludes.Any(p => norm.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string path) =>
|
||||||
|
path.Trim().Replace('/', '\\').TrimEnd('\\');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `using ClaudeDo.Data.Repositories;` if not already present (it is, via existing usings).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the test — expect PASS.**
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||||
|
git commit -m "feat(daily-prep): add get_daily_prep_candidates MCP tool"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `set_my_day` MCP tool with cap-guard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests.**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task SetMyDay_sets_flag_and_sort_order()
|
||||||
|
{
|
||||||
|
var svc = NewService();
|
||||||
|
var id = await SeedIdleTask("My task"); // existing/added helper returning task id
|
||||||
|
|
||||||
|
var dto = await svc.SetMyDay(id, isMyDay: true, sortOrder: 3, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(dto.IsMyDay);
|
||||||
|
Assert.Equal(3, dto.SortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetMyDay_rejects_when_cap_reached()
|
||||||
|
{
|
||||||
|
// AppSettings.DailyPrepMaxTasks = 1 (set in seed)
|
||||||
|
var svc = NewService();
|
||||||
|
var first = await SeedIdleTask("a");
|
||||||
|
var second = await SeedIdleTask("b");
|
||||||
|
await svc.SetMyDay(first, true, null, CancellationToken.None);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => svc.SetMyDay(second, true, null, CancellationToken.None));
|
||||||
|
Assert.Contains("limit", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetMyDay_unset_is_always_allowed()
|
||||||
|
{
|
||||||
|
var svc = NewService();
|
||||||
|
var id = await SeedIdleTask("a");
|
||||||
|
await svc.SetMyDay(id, true, null, CancellationToken.None);
|
||||||
|
|
||||||
|
var dto = await svc.SetMyDay(id, false, null, CancellationToken.None);
|
||||||
|
Assert.False(dto.IsMyDay);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SetMyDay` returns the existing `TaskDto`. Add a `SortOrder` field to `TaskDto` — see Step 3a. (`SeedIdleTask` / the `DailyPrepMaxTasks=1` seed reuse the file's existing seeding helpers.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL.**
|
||||||
|
|
||||||
|
- [ ] **Step 3a: Add `SortOrder` to `TaskDto`** (record + `ToDto`) so the result reflects ordering:
|
||||||
|
|
||||||
|
In the `TaskDto` record add `int SortOrder` as the last positional member, and in `ToDto(TaskEntity t)` add `t.SortOrder` as the last argument. (Update any test that constructs `TaskDto` positionally — search the test project.)
|
||||||
|
|
||||||
|
- [ ] **Step 3b: Add the tool method:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " +
|
||||||
|
"(use consecutive sortOrder values to keep related tasks together). " +
|
||||||
|
"Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " +
|
||||||
|
"clearing (isMyDay=false) is always allowed.")]
|
||||||
|
public async Task<TaskDto> SetMyDay(
|
||||||
|
string taskId,
|
||||||
|
bool isMyDay,
|
||||||
|
int? sortOrder,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
|
||||||
|
if (isMyDay && !task.IsMyDay)
|
||||||
|
{
|
||||||
|
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
|
||||||
|
var max = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
|
||||||
|
var openMyDay = await ctx.Tasks.CountAsync(
|
||||||
|
t => t.IsMyDay && t.Status == TaskStatus.Idle, cancellationToken);
|
||||||
|
if (openMyDay >= max)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"MyDay limit {max} reached. Clear a task before adding another.");
|
||||||
|
}
|
||||||
|
|
||||||
|
task.IsMyDay = isMyDay;
|
||||||
|
if (sortOrder is not null) task.SortOrder = sortOrder.Value;
|
||||||
|
await ctx.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
|
return ToDto(task);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run — expect PASS.**
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests
|
||||||
|
git commit -m "feat(daily-prep): add set_my_day MCP tool with cap-guard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Rewrite `PrimeRunner` to run the daily prep
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs`, and extend `PrimeRunnerTests.cs` if it exists
|
||||||
|
|
||||||
|
The runner needs the cap `X` (read from `AppSettings`) and today's date. Inject `IDbContextFactory<ClaudeDoDbContext>` into `PrimeRunner` (it is resolvable in the main app DI) and an `IPrimeClock` for the date (already registered).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing prompt/args tests.**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class DailyPrepPromptTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Build_prompt_contains_cap_and_date()
|
||||||
|
{
|
||||||
|
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks: 5, today: new DateOnly(2026, 6, 3));
|
||||||
|
Assert.Contains("5", prompt);
|
||||||
|
Assert.Contains("2026-06-03", prompt);
|
||||||
|
Assert.Contains("get_daily_prep_candidates", prompt);
|
||||||
|
Assert.Contains("set_my_day", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_args_allows_only_the_two_tools()
|
||||||
|
{
|
||||||
|
var args = DailyPrepPrompt.BuildArgs(maxTurns: 30);
|
||||||
|
Assert.Contains("--output-format stream-json", args);
|
||||||
|
Assert.Contains("--max-turns 30", args);
|
||||||
|
Assert.Contains("--allowedTools", args);
|
||||||
|
Assert.Contains("mcp__claudedo__get_daily_prep_candidates", args);
|
||||||
|
Assert.Contains("mcp__claudedo__set_my_day", args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `DailyPrepPrompt.cs`:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Worker.Prime;
|
||||||
|
|
||||||
|
public static class DailyPrepPrompt
|
||||||
|
{
|
||||||
|
public const string CandidatesTool = "mcp__claudedo__get_daily_prep_candidates";
|
||||||
|
public const string SetMyDayTool = "mcp__claudedo__set_my_day";
|
||||||
|
|
||||||
|
public static string BuildArgs(int maxTurns) =>
|
||||||
|
"-p --output-format stream-json --verbose --permission-mode acceptEdits " +
|
||||||
|
$"--max-turns {maxTurns} " +
|
||||||
|
$"--allowedTools {CandidatesTool} {SetMyDayTool}";
|
||||||
|
|
||||||
|
public static string BuildPrompt(int maxTasks, DateOnly today) =>
|
||||||
|
$"""
|
||||||
|
Du bereitest meinen Arbeitstag fuer {today:yyyy-MM-dd} vor.
|
||||||
|
|
||||||
|
1. Rufe {CandidatesTool} auf.
|
||||||
|
2. Behalte bereits als MyDay markierte offene Tasks (currentMyDay) — entferne sie nicht.
|
||||||
|
3. Fuelle bis maximal {maxTasks} offene Tasks GESAMT in MyDay auf (currentMyDay zaehlt mit). Niemals mehr.
|
||||||
|
4. Schaetze pro Kandidat grob den Aufwand und waehle eine machbare Mischung (nicht nur Grossbrocken).
|
||||||
|
Priorisiere isStarred, faellige (scheduledFor) und aeltere Tasks.
|
||||||
|
5. Lege thematisch verwandte Tasks durch aufeinanderfolgende sortOrder-Werte nebeneinander.
|
||||||
|
6. Setze die Auswahl via {SetMyDayTool}(taskId, true, sortOrder). Markiere nichts ausserhalb der Kandidatenliste.
|
||||||
|
|
||||||
|
Wenn es keine Kandidaten gibt, tue nichts.
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run prompt tests — expect PASS.**
|
||||||
|
|
||||||
|
- [ ] **Step 5: Rewrite `PrimeRunner.cs`:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Prime;
|
||||||
|
|
||||||
|
public sealed class PrimeRunner : IPrimeRunner
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan FireTimeout = TimeSpan.FromMinutes(5);
|
||||||
|
private const int MaxTurns = 30;
|
||||||
|
|
||||||
|
private readonly IClaudeProcess _claude;
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly IPrimeClock _clock;
|
||||||
|
private readonly ILogger<PrimeRunner> _logger;
|
||||||
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
|
public PrimeRunner(
|
||||||
|
IClaudeProcess claude,
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
IPrimeClock clock,
|
||||||
|
ILogger<PrimeRunner> logger)
|
||||||
|
{
|
||||||
|
_claude = claude;
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_clock = clock;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!await _gate.WaitAsync(0, ct))
|
||||||
|
return new PrimeRunOutcome(false, "Daily prep already running");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cwd = Paths.AppDataRoot();
|
||||||
|
Directory.CreateDirectory(cwd);
|
||||||
|
|
||||||
|
int maxTasks;
|
||||||
|
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
|
||||||
|
{
|
||||||
|
var settings = await new AppSettingsRepository(dbCtx).GetAsync(ct);
|
||||||
|
maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime);
|
||||||
|
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks, today);
|
||||||
|
var args = DailyPrepPrompt.BuildArgs(MaxTurns);
|
||||||
|
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeoutCts.CancelAfter(FireTimeout);
|
||||||
|
|
||||||
|
var result = await _claude.RunAsync(
|
||||||
|
arguments: args,
|
||||||
|
prompt: prompt,
|
||||||
|
workingDirectory: cwd,
|
||||||
|
onStdoutLine: _ => Task.CompletedTask,
|
||||||
|
ct: timeoutCts.Token);
|
||||||
|
|
||||||
|
return result.IsSuccess
|
||||||
|
? new PrimeRunOutcome(true, "Daily prep complete")
|
||||||
|
: new PrimeRunOutcome(false, $"exit code {result.ExitCode}");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Daily prep run failed");
|
||||||
|
return new PrimeRunOutcome(false, ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Fix the DI registration is unchanged** (`AddSingleton<IPrimeRunner, PrimeRunner>()` already works — the new ctor deps `IDbContextFactory` and `IPrimeClock` are registered). Build the Worker.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Update/extend `PrimeRunnerTests.cs`** (if present) to match the new ctor: construct `PrimeRunner` with a fake `IClaudeProcess`, a real temp-SQLite `IDbContextFactory`, a fake `IPrimeClock`, and a logger. Add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task FireAsync_returns_already_running_when_gate_held()
|
||||||
|
{
|
||||||
|
var runner = NewRunner(claudeDelay: TimeSpan.FromSeconds(2));
|
||||||
|
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
|
||||||
|
|
||||||
|
var first = runner.FireAsync(schedule, CancellationToken.None);
|
||||||
|
var second = await runner.FireAsync(schedule, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(second.Success);
|
||||||
|
Assert.Contains("already running", second.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
await first;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If no `PrimeRunnerTests.cs` exists, create one. The fake `IClaudeProcess` should optionally delay (to keep the gate held) and return a successful `RunResult { ExitCode = 0, ResultMarkdown = "ok" }`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run — expect PASS.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "DailyPrepPrompt|PrimeRunner"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Prime tests/ClaudeDo.Worker.Tests/Prime
|
||||||
|
git commit -m "feat(daily-prep): run daily prep from PrimeRunner via allowed MCP tools"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Hub — `RunDailyPrepNow` + expose `DailyPrepMaxTasks`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
|
||||||
|
Read `WorkerHub.cs` first. It already exposes a `GetAppSettings`/`UpdateAppSettings` pair backed by a DTO record (the one carrying `ReportExcludedPaths`, `StandupWeekday`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `DailyPrepMaxTasks` to the hub AppSettings DTO record** (the record near the top of `WorkerHub.cs` that lists `ReportExcludedPaths`). Add `int DailyPrepMaxTasks` as a member. In the read mapping (`GetAppSettings`, where `row.ReportExcludedPaths` is read) add `row.DailyPrepMaxTasks`; in the write mapping (`UpdateAppSettings`, where `ReportExcludedPaths = dto.ReportExcludedPaths`) add `DailyPrepMaxTasks = dto.DailyPrepMaxTasks`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the hub method.** Inject `IPrimeRunner` and `HubBroadcaster` if the hub does not already have them (the hub is constructed by SignalR via DI; both are registered singletons). Then:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<bool> RunDailyPrepNow()
|
||||||
|
{
|
||||||
|
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
|
||||||
|
var firedAt = DateTimeOffset.Now;
|
||||||
|
var outcome = await _primeRunner.FireAsync(schedule, Context.ConnectionAborted);
|
||||||
|
await _broadcaster.PrimeFired(Guid.Empty, outcome.Success, outcome.Message, firedAt);
|
||||||
|
return outcome.Success;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `using ClaudeDo.Worker.Prime;` to `WorkerHub.cs` if missing.
|
||||||
|
|
||||||
|
> **Caution (memory):** changing the `WorkerHub` constructor breaks hand-rolled hub-test fakes in `ClaudeDo.Worker.Tests` and possibly `ClaudeDo.Ui.Tests`. After editing, build the test projects and fix every `new WorkerHub(...)` / fake `IWorkerClient` construction the compiler flags.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Mirror the DTO in the UI** (`WorkerClient.cs`, the AppSettings DTO around line 498): add `int DailyPrepMaxTasks` to the record (same position as in the hub DTO). Add a `RunDailyPrepNow` client call:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<bool> RunDailyPrepNowAsync() =>
|
||||||
|
_connection.InvokeAsync<bool>("RunDailyPrepNow");
|
||||||
|
```
|
||||||
|
|
||||||
|
(Match the exact connection field/name and the async-wrapper style used by neighbouring calls like `GenerateWeekReport`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build Worker + App + test projects; fix any broken fakes.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/WorkerClient.cs tests
|
||||||
|
git commit -m "feat(daily-prep): add RunDailyPrepNow hub method and expose DailyPrepMaxTasks"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Settings UI — edit `DailyPrepMaxTasks`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs`
|
||||||
|
- Modify: the Prime Claude tab markup in `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` (load/save wiring, where other AppSettings fields are mapped)
|
||||||
|
|
||||||
|
Read these three files first; mirror how an existing numeric AppSetting (e.g. `MaxParallelExecutions` or `WorktreeAutoCleanupDays`) is loaded from the hub DTO, bound, and saved back.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add an observable property** to `PrimeClaudeTabViewModel.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty] private int _dailyPrepMaxTasks = 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire load/save** in `SettingsModalViewModel.cs`: where the AppSettings DTO is read into the tabs, set `PrimeClaude.DailyPrepMaxTasks = dto.DailyPrepMaxTasks;`. Where the DTO is written, include `DailyPrepMaxTasks = PrimeClaude.DailyPrepMaxTasks`. (Use the exact tab property name for the Prime Claude tab in that VM.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the editor** in the Prime Claude tab of `SettingsModalView.axaml`, near the schedule list:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{x:Static loc:L.Settings_DailyPrepMaxTasks}" VerticalAlignment="Center"/>
|
||||||
|
<NumericUpDown Minimum="1" Maximum="50" Increment="1" Width="100"
|
||||||
|
Value="{Binding PrimeClaude.DailyPrepMaxTasks}"/>
|
||||||
|
</StackPanel>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the `Settings_DailyPrepMaxTasks` key to both `locales/en.json` and `locales/de.json` (en: "Max tasks per day", de: "Max. Aufgaben pro Tag"). If the tab does not use localized labels yet, use a plain `Text="Max tasks per day"` string to match its current style.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the App; smoke-build the UI.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui src/ClaudeDo.Localization
|
||||||
|
git commit -m "feat(daily-prep): add DailyPrepMaxTasks editor to Prime Claude settings"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: MyDay header — "Tag vorbereiten" button
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: the ViewModel backing the MyDay list view (the one that exposes the smart-list header/toolbar; find it under `src/ClaudeDo.Ui/ViewModels/Islands/` — likely the tasks/list island VM that has access to `IWorkerClient`)
|
||||||
|
- Modify: the corresponding view (`.axaml`) that renders the list header
|
||||||
|
|
||||||
|
Read the island VM + view first. Find where the active list is known to be `smart:my-day` so the button can be shown only there (mirror any existing conditional header content). The VM already holds a worker-client reference used by other commands (e.g. RunNow) — reuse it.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the command** to the island VM:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task PrepareDayAsync()
|
||||||
|
{
|
||||||
|
await _workerClient.RunDailyPrepNowAsync();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Use the VM's existing worker-client field name. The MyDay list refreshes automatically via the `TaskUpdated` broadcast the tools emit, so no manual reload is needed.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add an `IsMyDayList` (or reuse existing selected-list) guard** so the button only appears on the MyDay smart list. If the VM already exposes the selected list id, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public bool IsMyDayList => SelectedListId == "smart:my-day";
|
||||||
|
```
|
||||||
|
|
||||||
|
and raise its change notification wherever `SelectedListId` changes (mirror existing patterns; if a `[NotifyPropertyChangedFor]` or manual `OnPropertyChanged` is already used for the selection, add this property to it).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the button** to the list header in the view, visible only on MyDay:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Button Content="{x:Static loc:L.MyDay_PrepareDay}"
|
||||||
|
Command="{Binding PrepareDayCommand}"
|
||||||
|
IsVisible="{Binding IsMyDayList}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `MyDay_PrepareDay` to `locales/en.json` ("Prepare day") and `locales/de.json` ("Tag vorbereiten"), or a plain string if the view is not localized.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the App.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual smoke (cannot be unit-tested):** start the Worker and App, open MyDay, click "Tag vorbereiten", confirm tasks appear (capped) and the button is hidden on other lists. Report results explicitly — do not claim UI success without running it.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui src/ClaudeDo.Localization
|
||||||
|
git commit -m "feat(daily-prep): add Prepare-day button to MyDay header"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
- [ ] `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
- [ ] `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||||
|
- [ ] `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
|
||||||
|
- [ ] `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release`
|
||||||
|
- [ ] `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||||
|
- [ ] End-to-end manual run: schedule fires (or button) → Claude calls the two tools → MyDay gets a capped subset; re-run keeps existing MyDay and tops up without exceeding the cap.
|
||||||
|
|
||||||
|
## Notes / risks
|
||||||
|
|
||||||
|
- Relies on the globally registered `claudedo` MCP (installer `RegisterMcpStep`). If absent, the prep run produces 0 changes — acceptable for v1.
|
||||||
|
- `--permission-mode acceptEdits` + explicit `--allowedTools` pre-approves exactly the two tools so the headless run never blocks on a permission prompt.
|
||||||
|
- The cap-guard counts `Idle && IsMyDay` tasks; it is the source of truth for the "never move everything in" invariant regardless of Claude's behavior.
|
||||||
|
- Future phase (out of scope): external ticket sources (Jira) feed into `get_daily_prep_candidates` behind a task-source abstraction.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,994 @@
|
|||||||
|
# Approve = Merge → Done + Conflict Preview — 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:** Approving a `WaitingForReview` task merges its worktree into the target branch first and only marks the task `Done` on a clean merge; conflicts keep it in review and are surfaced. Add a non-destructive "merges cleanly / conflicts" indicator and a direct single-task Merge button.
|
||||||
|
|
||||||
|
**Architecture:** A new `GitService.PreviewMergeAsync` probes mergeability via `git merge-tree --write-tree` (no working-tree mutation). `TaskMergeService` gains `PreviewAsync` and `ApproveAndMergeAsync` (merge first, then delegate the `Done` flip to `ITaskStateService`). `WorkerHub` exposes `PreviewMerge` and a result-returning `ApproveReview(taskId, targetBranch)`. The UI loads merge targets whenever a worktree exists, shows the preview, and reacts to conflict results.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, Avalonia, EF Core/SQLite, SignalR, xUnit with real git (`GitRepoFixture`) and real SQLite (`DbFixture`).
|
||||||
|
|
||||||
|
**Conventions for the implementer:**
|
||||||
|
- Use the **sonnet** model.
|
||||||
|
- **Stage files explicitly by path** — never `git add -A` (parallel sessions leave unrelated WIP).
|
||||||
|
- Build with `-c Release` (a running Worker locks `Debug` output).
|
||||||
|
- Conventional Commit messages: `type(scope): description`.
|
||||||
|
- New UI strings use **plain English literals** to match the surrounding merge controls (no `loc:Tr`) — this avoids Localization.Tests parity churn.
|
||||||
|
- Ignore anything under `.claude/worktrees/` — those are stale worktrees, not the build tree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/ClaudeDo.Data/Git/GitService.cs` | Add `MergePreview` record + `PreviewMergeAsync` + `CountChangedFilesAsync` |
|
||||||
|
| `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` | Inject `ITaskStateService`; add `MergePreviewResult` + `PreviewAsync` + `ApproveAndMergeAsync` |
|
||||||
|
| `src/ClaudeDo.Worker/Hub/WorkerHub.cs` | Add `MergePreviewDto` + `PreviewMerge`; change `ApproveReview` to `(taskId, targetBranch) → MergeResultDto` |
|
||||||
|
| `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` | Change `ApproveReviewAsync`; add `PreviewMergeAsync`, `MergeTaskAsync` |
|
||||||
|
| `src/ClaudeDo.Ui/Services/WorkerClient.cs` | Implement the above; add UI `MergePreviewDto` record |
|
||||||
|
| `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs` | New pure presenter (text + color flags) |
|
||||||
|
| `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` | Load targets for worktree tasks; preview props; approve conflict handling; `MergeCommand` |
|
||||||
|
| `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` | Update the list-level approve call to new signature |
|
||||||
|
| `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` | Mergeability status line + Merge button |
|
||||||
|
| `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs` | New — git-backed preview tests |
|
||||||
|
| `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` | Update `BuildService`; add preview + approve-merge tests |
|
||||||
|
| `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` | Update `FakeWorkerClient` |
|
||||||
|
| `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` | Update fake |
|
||||||
|
| `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` | Update the `ApproveReviewAsync` override |
|
||||||
|
| `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs` | New — presenter unit tests |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: GitService non-destructive merge probe
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/Git/GitService.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs` (create)
|
||||||
|
|
||||||
|
Behaviour verified on git 2.50: `git merge-tree --write-tree --name-only <target> <source>` exits `0` when clean (stdout = a single tree-OID line) and `1` on conflict (stdout = tree-OID line, then conflicted file names, then a blank line, then informational messages). It writes only loose objects — the working tree, index, and refs are untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Runner;
|
||||||
|
|
||||||
|
public class GitServicePreviewMergeTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<GitRepoFixture> _repos = new();
|
||||||
|
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
|
||||||
|
public void Dispose() { foreach (var r in _repos) try { r.Dispose(); } catch { } }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMergeAsync_NonConflicting_ReportsCleanWithChangedCount()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
var repo = NewRepo();
|
||||||
|
var git = new GitService();
|
||||||
|
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "newfile.txt"), "x\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
|
||||||
|
|
||||||
|
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(preview.Supported);
|
||||||
|
Assert.True(preview.Clean);
|
||||||
|
Assert.Empty(preview.ConflictFiles);
|
||||||
|
|
||||||
|
var count = await git.CountChangedFilesAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
|
||||||
|
Assert.Equal(1, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewMergeAsync_Conflicting_ReportsFilesAndDoesNotMutateTree()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
var repo = NewRepo();
|
||||||
|
var git = new GitService();
|
||||||
|
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from feature\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat readme");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from base\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "base readme");
|
||||||
|
|
||||||
|
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||||
|
|
||||||
|
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(preview.Supported);
|
||||||
|
Assert.False(preview.Clean);
|
||||||
|
Assert.Contains("README.md", preview.ConflictFiles);
|
||||||
|
|
||||||
|
// Non-destructive: HEAD unchanged, no mid-merge state.
|
||||||
|
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
||||||
|
Assert.False(await git.IsMidMergeAsync(repo.RepoDir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests, verify they fail to compile**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~GitServicePreviewMergeTests`
|
||||||
|
Expected: build error — `PreviewMergeAsync`/`CountChangedFilesAsync` do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the probe**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Data/Git/GitService.cs`, add this record just under `namespace ClaudeDo.Data.Git;`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add these methods inside the `GitService` class (e.g. after `ListConflictedFilesAsync`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
|
||||||
|
/// loose objects — the working tree, index, and refs are left untouched.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<MergePreview> PreviewMergeAsync(
|
||||||
|
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
|
||||||
|
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
|
||||||
|
|
||||||
|
if (exitCode == 0)
|
||||||
|
return new MergePreview(true, true, Array.Empty<string>());
|
||||||
|
|
||||||
|
if (exitCode == 1)
|
||||||
|
{
|
||||||
|
// stdout: <tree-oid>\n<file>\n...\n\n<informational messages>
|
||||||
|
var lines = stdout.Split('\n');
|
||||||
|
var files = new List<string>();
|
||||||
|
for (int i = 1; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
var line = lines[i].TrimEnd('\r');
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) break;
|
||||||
|
files.Add(line.Trim());
|
||||||
|
}
|
||||||
|
return new MergePreview(true, false, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other exit (e.g. git too old: "unknown option --write-tree").
|
||||||
|
return new MergePreview(false, false, Array.Empty<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
|
||||||
|
public async Task<int> CountChangedFilesAsync(
|
||||||
|
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
|
||||||
|
["diff", "--name-only", $"{targetBranch}...{sourceBranch}"], ct);
|
||||||
|
if (exitCode != 0) return 0;
|
||||||
|
return stdout
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Count(s => s.Length > 0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests, verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~GitServicePreviewMergeTests`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/Git/GitService.cs tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs
|
||||||
|
git commit -m "feat(git): add non-destructive merge-tree conflict probe"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: TaskMergeService preview + approve-merge orchestration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
|
||||||
|
|
||||||
|
`ApproveAndMergeAsync` merges first (reusing `MergeAsync`, `removeWorktree:false`) and only then delegates the `Done` flip to `ITaskStateService.ApproveReviewAsync` (the sole owner of Status writes). Conflicts/blocks return without flipping status. No DI cycle: `TaskStateService` and `PlanningChainCoordinator` do not depend on `TaskMergeService`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `BuildService` and add failing tests**
|
||||||
|
|
||||||
|
In `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`, replace the `BuildService` helper so it also constructs a real `TaskStateService` (existing merge tests still pass — they only inspect the merge service's own broadcaster proxy):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(DbFixture db)
|
||||||
|
{
|
||||||
|
var fakeHub = new MergeRecordingHubContext();
|
||||||
|
var broadcaster = new HubBroadcaster(fakeHub);
|
||||||
|
var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
|
||||||
|
var svc = new TaskMergeService(
|
||||||
|
db.CreateFactory(),
|
||||||
|
new GitService(),
|
||||||
|
broadcaster,
|
||||||
|
state,
|
||||||
|
NullLogger<TaskMergeService>.Instance);
|
||||||
|
return (svc, fakeHub.Proxy);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add these tests to the class:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewAsync_CleanWorktree_ReturnsClean()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||||
|
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "x\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
|
||||||
|
Assert.True(preview.ChangedFileCount >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewAsync_Conflict_ReturnsConflictFiles()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||||
|
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.PreviewConflict, preview.Status);
|
||||||
|
Assert.Contains("README.md", preview.ConflictFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
|
||||||
|
var preview = await svc.PreviewAsync(task.Id, "main", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.PreviewUnavailable, preview.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveAndMergeAsync_CleanWorktree_MergesAndMarksDone()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||||
|
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Done, updated!.Status);
|
||||||
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||||
|
Assert.Equal(WorktreeState.Merged, wt!.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||||
|
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
||||||
|
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
|
||||||
|
Assert.Contains("README.md", result.ConflictFiles);
|
||||||
|
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
|
||||||
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||||
|
Assert.Equal(WorktreeState.Active, wt!.State);
|
||||||
|
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
||||||
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
|
||||||
|
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Done, updated!.Status);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests, verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TaskMergeServiceTests`
|
||||||
|
Expected: build error — `ITaskStateService` ctor arg, `PreviewAsync`, `ApproveAndMergeAsync`, `PreviewClean/PreviewConflict/PreviewUnavailable` do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement in TaskMergeService**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`:
|
||||||
|
|
||||||
|
Add `using ClaudeDo.Worker.State;` to the usings.
|
||||||
|
|
||||||
|
Add the preview-result record beside `MergeTargets`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record MergePreviewResult(
|
||||||
|
string Status,
|
||||||
|
IReadOnlyList<string> ConflictFiles,
|
||||||
|
int ChangedFileCount);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the status constants beside the existing `StatusMerged` etc.:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public const string PreviewClean = "clean";
|
||||||
|
public const string PreviewConflict = "conflict";
|
||||||
|
public const string PreviewUnavailable = "unavailable";
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the field and constructor param (inject `ITaskStateService`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private readonly ITaskStateService _state;
|
||||||
|
|
||||||
|
public TaskMergeService(
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
GitService git,
|
||||||
|
HubBroadcaster broadcaster,
|
||||||
|
ITaskStateService state,
|
||||||
|
ILogger<TaskMergeService> logger)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_git = git;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
_state = state;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the two methods (e.g. after `GetTargetsAsync`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|
||||||
|
if (wt is null || wt.State != WorktreeState.Active)
|
||||||
|
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
||||||
|
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
||||||
|
|
||||||
|
var target = string.IsNullOrWhiteSpace(targetBranch)
|
||||||
|
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
||||||
|
: targetBranch;
|
||||||
|
|
||||||
|
var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct);
|
||||||
|
if (!preview.Supported)
|
||||||
|
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
||||||
|
if (!preview.Clean)
|
||||||
|
return new MergePreviewResult(PreviewConflict, preview.ConflictFiles, 0);
|
||||||
|
|
||||||
|
var count = await _git.CountChangedFilesAsync(list.WorkingDir, target, wt.BranchName, ct);
|
||||||
|
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|
||||||
|
if (task.Status != TaskStatus.WaitingForReview)
|
||||||
|
return Blocked("task is not waiting for review");
|
||||||
|
|
||||||
|
// No worktree to merge (sandbox run, or an improvement parent whose children own
|
||||||
|
// the worktrees) — approve straight to Done.
|
||||||
|
if (wt is null || wt.State != WorktreeState.Active)
|
||||||
|
{
|
||||||
|
var done = await _state.ApproveReviewAsync(taskId, ct);
|
||||||
|
return done.Ok
|
||||||
|
? new MergeResult(StatusMerged, Array.Empty<string>(), null)
|
||||||
|
: Blocked(done.Reason ?? "approve failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = string.IsNullOrWhiteSpace(targetBranch)
|
||||||
|
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
||||||
|
: targetBranch;
|
||||||
|
|
||||||
|
var merge = await MergeAsync(taskId, target, removeWorktree: false, $"Merge {wt.BranchName}", ct);
|
||||||
|
if (merge.Status != StatusMerged)
|
||||||
|
return merge; // conflict or blocked — leave the task in WaitingForReview
|
||||||
|
|
||||||
|
var approve = await _state.ApproveReviewAsync(taskId, ct);
|
||||||
|
return approve.Ok ? merge : Blocked(approve.Reason ?? "approve failed");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests, verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TaskMergeServiceTests`
|
||||||
|
Expected: PASS (all existing + 6 new).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
|
||||||
|
git commit -m "feat(worker): approve merges worktree before marking task done"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: WorkerHub — PreviewMerge + result-returning ApproveReview
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
|
||||||
|
This is SignalR wiring (no unit test); verify by building the Worker.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the DTO**
|
||||||
|
|
||||||
|
Beside the existing `MergeResultDto`/`MergeTargetsDto` records (around line 56):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `PreviewMerge` and replace `ApproveReview`**
|
||||||
|
|
||||||
|
Add a `PreviewMerge` method beside `GetMergeTargets`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var p = await _mergeService.PreviewAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
||||||
|
return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the existing `ApproveReview` method (currently lines ~383-387, delegating to `_state.ApproveReviewAsync`) with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
||||||
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
|
throw new HubException(r.ErrorMessage ?? "approve failed");
|
||||||
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
(Conflicts are returned, not thrown, so the UI can display the conflicting files; only hard blocks throw.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the Worker, verify green**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||||
|
Expected: Build succeeded. (DI resolves the new `ITaskStateService` dependency of `TaskMergeService` automatically — it is already registered.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
|
||||||
|
git commit -m "feat(worker): expose PreviewMerge hub method and merge-on-approve"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: UI client + interface + test fakes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (caller at line 648)
|
||||||
|
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (`FakeWorkerClient`)
|
||||||
|
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
|
||||||
|
- Modify: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (override)
|
||||||
|
|
||||||
|
Note: `DetailsIslandViewModel.ApproveReviewAsync` (line 1368) is updated in Task 5, not here — but the interface change forces it to compile, so Task 5 must follow before the Ui project builds. To keep this task self-contained and green on its own, update that call site here too (the conflict-handling logic lands in Task 5).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the UI DTO**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, beside the existing `MergeResultDto`/`MergeTargetsDto` records (lines 521-522):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the interface**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, replace `Task ApproveReviewAsync(string taskId);` (line 40) with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
|
||||||
|
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
|
||||||
|
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
||||||
|
```
|
||||||
|
|
||||||
|
(`MergeTaskAsync` already exists on the concrete `WorkerClient` — this only adds it to the interface.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the concrete client**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, replace the existing `ApproveReviewAsync` (line ~389) and add `PreviewMergeAsync`. Mirror the existing `GetMergeTargetsAsync` pattern (it uses the `TryInvokeAsync<T>` helper which returns `null` when disconnected):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
||||||
|
=> TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
|
||||||
|
|
||||||
|
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
|
||||||
|
=> TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch);
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure the existing `public async Task<MergeResultDto> MergeTaskAsync(...)` signature matches the interface exactly (params: `string taskId, string targetBranch, bool removeWorktree, string commitMessage`). Leave its body as-is.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the two callers**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` line 648 — the list-level quick approve has no merge-target selector, so it merges into the repo's current branch (empty string resolves server-side):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
try { await _worker.ApproveReviewAsync(row.Id, ""); }
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` line 1368 — update to the new signature for now (full conflict handling is added in Task 5):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
try { await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? ""); }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update the three test fakes**
|
||||||
|
|
||||||
|
`tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` line 53 — replace and add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
||||||
|
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
||||||
|
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` line 45 (`FakeWorkerClient`) — replace and add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
||||||
|
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
||||||
|
public Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||||
|
```
|
||||||
|
|
||||||
|
(Confirm whether `FakeWorkerClient` already implements `MergeTaskAsync`; if so, only change `ApproveReviewAsync` and add `PreviewMergeAsync`. Add `using` for the DTO namespace if needed — same namespace as `IWorkerClient`.)
|
||||||
|
|
||||||
|
`tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` line 77 — update the override signature:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||||
|
/* keep whatever recording/behavior this override had, now returning Task<MergeResultDto?> */
|
||||||
|
Task.FromResult<MergeResultDto?>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
(Preserve any side effect the existing override performed — e.g. recording the call — just change the signature and return type.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build UI + run both UI-touching test projects**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TasksIslandViewModelPlanning`
|
||||||
|
Expected: all green.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
|
||||||
|
git commit -m "feat(ui): wire merge-aware approve and preview into the worker client"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Mergeability presenter + DetailsIslandViewModel wiring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs` (create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing presenter tests**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class MergePreviewPresenterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Clean_Plural()
|
||||||
|
{
|
||||||
|
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("clean", System.Array.Empty<string>(), 3));
|
||||||
|
Assert.Equal("Merges cleanly · 3 files", text);
|
||||||
|
Assert.True(clean);
|
||||||
|
Assert.False(conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clean_Singular()
|
||||||
|
{
|
||||||
|
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("clean", System.Array.Empty<string>(), 1));
|
||||||
|
Assert.Equal("Merges cleanly · 1 file", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Conflict_ListsUpToThree()
|
||||||
|
{
|
||||||
|
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("conflict", new[] { "a.cs", "b.cs" }, 0));
|
||||||
|
Assert.Equal("Conflicts in a.cs, b.cs", text);
|
||||||
|
Assert.False(clean);
|
||||||
|
Assert.True(conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Conflict_TruncatesWithMore()
|
||||||
|
{
|
||||||
|
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("conflict", new[] { "a", "b", "c", "d", "e" }, 0));
|
||||||
|
Assert.Equal("Conflicts in a, b, c (+2 more)", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unavailable_IsMuted()
|
||||||
|
{
|
||||||
|
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("unavailable", System.Array.Empty<string>(), 0));
|
||||||
|
Assert.Equal("Mergeability unknown", text);
|
||||||
|
Assert.False(clean);
|
||||||
|
Assert.False(conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_IsEmpty()
|
||||||
|
{
|
||||||
|
var (text, clean, conflict) = MergePreviewPresenter.Describe(null);
|
||||||
|
Assert.Equal("", text);
|
||||||
|
Assert.False(clean);
|
||||||
|
Assert.False(conflict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run, verify it fails to compile**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~MergePreviewPresenterTests`
|
||||||
|
Expected: build error — `MergePreviewPresenter` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the presenter**
|
||||||
|
|
||||||
|
Create `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Linq;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
/// Pure mapping from a merge-preview DTO to display text + color flags.
|
||||||
|
public static class MergePreviewPresenter
|
||||||
|
{
|
||||||
|
public static (string Text, bool IsClean, bool IsConflict) Describe(MergePreviewDto? dto)
|
||||||
|
{
|
||||||
|
if (dto is null) return ("", false, false);
|
||||||
|
|
||||||
|
switch (dto.Status)
|
||||||
|
{
|
||||||
|
case "clean":
|
||||||
|
var unit = dto.ChangedFileCount == 1 ? "file" : "files";
|
||||||
|
return ($"Merges cleanly · {dto.ChangedFileCount} {unit}", true, false);
|
||||||
|
|
||||||
|
case "conflict":
|
||||||
|
var names = string.Join(", ", dto.ConflictFiles.Take(3));
|
||||||
|
var more = dto.ConflictFiles.Count > 3 ? $" (+{dto.ConflictFiles.Count - 3} more)" : "";
|
||||||
|
return ($"Conflicts in {names}{more}", false, true);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ("Mergeability unknown", false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run, verify the presenter tests pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~MergePreviewPresenterTests`
|
||||||
|
Expected: PASS (6 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Wire the presenter into DetailsIslandViewModel**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`:
|
||||||
|
|
||||||
|
(a) Add observable properties (near the other merge properties, ~line 334):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||||
|
private string _mergePreviewText = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||||
|
private bool _mergeIsClean;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||||
|
private bool _mergeIsConflict;
|
||||||
|
|
||||||
|
public bool ShowMergePreviewMuted =>
|
||||||
|
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
|
||||||
|
|
||||||
|
public bool ShowSingleMerge =>
|
||||||
|
WorktreePath != null && Task?.IsPlanningParent != true;
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Add the refresh method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async System.Threading.Tasks.Task RefreshMergePreviewAsync()
|
||||||
|
{
|
||||||
|
if (Task is null || WorktreePath is null)
|
||||||
|
{
|
||||||
|
MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only probe Active worktrees; terminal states show their label instead.
|
||||||
|
if (WorktreeStateLabel is { } label && label != "Active")
|
||||||
|
{
|
||||||
|
MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dto = await _worker.PreviewMergeAsync(Task.Id, SelectedMergeTarget ?? "");
|
||||||
|
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
|
||||||
|
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(c) Recompute when the merge target changes — add (or extend) the generated partial:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
partial void OnSelectedMergeTargetChanged(string? value)
|
||||||
|
{
|
||||||
|
_ = RefreshMergePreviewAsync();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(d) Notify `ShowSingleMerge` when the worktree path changes. In the existing `OnWorktreePathChanged` (line ~1141) add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
OnPropertyChanged(nameof(ShowSingleMerge));
|
||||||
|
```
|
||||||
|
|
||||||
|
(e) Load merge targets for standalone worktree tasks. In `BindAsync`, after the `if (entity.PlanningPhase != None) {...} else {...}` block (~line 814), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (entity.Worktree != null
|
||||||
|
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
|
||||||
|
&& MergeTargetBranches.Count == 0)
|
||||||
|
{
|
||||||
|
var targets = await _worker.GetMergeTargetsAsync(row.Id);
|
||||||
|
if (targets != null)
|
||||||
|
{
|
||||||
|
MergeTargetBranches.Clear();
|
||||||
|
foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b);
|
||||||
|
SelectedMergeTarget = targets.DefaultBranch; // triggers OnSelectedMergeTargetChanged → preview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await RefreshMergePreviewAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
(f) Replace the body of `ApproveReviewAsync` (line ~1362) to surface conflicts:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
private async System.Threading.Tasks.Task ApproveReviewAsync()
|
||||||
|
{
|
||||||
|
if (Task is null || !_worker.IsConnected) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
|
||||||
|
if (result?.Status == "conflict")
|
||||||
|
{
|
||||||
|
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||||
|
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* stale review action; broadcast reconciles */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(g) Add the single-task `MergeCommand` (place near `OpenDiffAsync`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
private async System.Threading.Tasks.Task MergeAsync()
|
||||||
|
{
|
||||||
|
if (Task is null || WorktreePath is null || !_worker.IsConnected) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
|
||||||
|
if (result.Status == "conflict")
|
||||||
|
{
|
||||||
|
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||||
|
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await RefreshMergePreviewAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* broadcast reconciles */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build UI + run the UI tests**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||||
|
Expected: green. (If `OnSelectedMergeTargetChanged` already exists, merge the new line into it instead of duplicating.)
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs
|
||||||
|
git commit -m "feat(ui): show mergeability and surface approve conflicts in the work console"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: WorkConsole — status line + Merge button
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`
|
||||||
|
|
||||||
|
No unit test (XAML); verified by build + manual visual check in Task 7.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the mergeability status line and the Merge button**
|
||||||
|
|
||||||
|
In the `MERGE & WORKTREE` `StackPanel` (starts line 196), insert the status line **between** the merge-target `StackPanel` (ends line 203) and the `<WrapPanel>` (line 204). Three single-line `TextBlock`s, one visible at a time by color:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource MossBrush}"
|
||||||
|
IsVisible="{Binding MergeIsClean}" />
|
||||||
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
|
IsVisible="{Binding MergeIsConflict}" />
|
||||||
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
IsVisible="{Binding ShowMergePreviewMuted}" />
|
||||||
|
</StackPanel>
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `<WrapPanel>` (line 204), add a **Merge** button immediately after the "Open Diff" button (line 206):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
|
||||||
|
Command="{Binding MergeCommand}"
|
||||||
|
IsVisible="{Binding ShowSingleMerge}" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build UI, verify green**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded (XAML compiles; all bound members exist from Task 5).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
|
||||||
|
git commit -m "feat(ui): add mergeability indicator and Merge button to work console"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Full build, full test, manual verification
|
||||||
|
|
||||||
|
**Files:** none (verification only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build the whole app + worker**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||||
|
Expected: both succeed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run all touched test projects**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||||
|
Expected: all green.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual verification (cannot be automated — no real Claude in tests)**
|
||||||
|
|
||||||
|
Start the Worker, then the App. Pick a list whose `WorkingDir` is a real git repo and use a task that already has an Active worktree (or create one).
|
||||||
|
|
||||||
|
Verify each acceptance criterion:
|
||||||
|
1. **Clean approve:** Open a `WaitingForReview` task whose worktree merges cleanly → the Session tab shows green "Merges cleanly · N files". Click **Approve** → the worktree merges into the target, the task becomes **Done**, and the worktree state becomes **Merged** (check the worktree overview).
|
||||||
|
2. **Conflicting approve:** Open a task whose worktree conflicts with the target → the Session tab shows red "Conflicts in …". Click **Approve** → the task stays **WaitingForReview** (NOT Done), the conflict line remains, and the target branch is unchanged.
|
||||||
|
3. **Done task preview:** Open a previously-Done task that was never merged (worktree still Active) → the merge/conflict status appears without any tree mutation; the **Merge** button merges it on demand.
|
||||||
|
|
||||||
|
Report the result of each check explicitly. If any visual issue appears (colors, layout, missing controls), note it for the user — do not claim the UI works without running it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- **Spec coverage:** Approve-merge (Task 2/3/5), conflict-keeps-review (Task 2 test + Task 5 surfacing), non-destructive preview (Task 1/2 + indicator in Task 5/6), real single-task Merge button (Task 5/6), standalone target-loading gap (Task 5e). All spec sections map to a task.
|
||||||
|
- **Type consistency:** `MergePreview` (Data) → `MergePreviewResult` (Worker service) → `MergePreviewDto` (hub + UI). Status strings `clean`/`conflict`/`unavailable` and merge statuses `merged`/`conflict`/`blocked` are used consistently across worker, client, presenter, and VM.
|
||||||
|
- **No new statuses, no DB migration, no localization keys** (literals match the surrounding controls).
|
||||||
|
- **External MCP unchanged:** `ExternalMcpService.ReviewTask` keeps calling `TaskStateService.ApproveReviewAsync` directly (its documented scope excludes merges); that method's signature is unchanged.
|
||||||
@@ -0,0 +1,972 @@
|
|||||||
|
# Bundled Prompts Overhaul 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:** Externalize every bundled prose prompt into editable files with strong defaults, collapse system+agent, and add an inline `CLAUDEDO_BLOCKED:` roadblock protocol surfaced at review.
|
||||||
|
|
||||||
|
**Architecture:** `PromptFiles` becomes the single source of prompt defaults + a pure token renderer. Each consumer (TaskRunner, PlanningSessionManager, DailyPrepPrompt, WeekReportPromptBuilder) reads its prompt via `PromptFiles`. `StreamAnalyzer` collects roadblock markers from streamed assistant text; the runner folds them into the review result.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, xUnit, EF Core (no schema change in this plan).
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-04-bundled-prompts-overhaul-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Data/PromptFiles.cs` — new `PromptKind` members, new defaults, `RenderTemplate` + `ReadOrDefault` + `Render`.
|
||||||
|
- `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs` — collect `Blocks` from assistant text.
|
||||||
|
- `src/ClaudeDo.Worker/Runner/RunResult.cs` — carry `Blocks`.
|
||||||
|
- `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs` — pass `Blocks`; expose no-result prefix const.
|
||||||
|
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — drop agent file; retry via `retry.md`; fold blocks into review result.
|
||||||
|
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — read planning prompts via `PromptFiles`.
|
||||||
|
- `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` — read `daily-prep.md`.
|
||||||
|
- `src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs` — read `weekly-report.md`.
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs` + its view — expose new prompt files, drop agent.
|
||||||
|
- Tests in `tests/ClaudeDo.Data.Tests` and `tests/ClaudeDo.Worker.Tests`.
|
||||||
|
|
||||||
|
Build commands (this repo is on .NET 8 — build per project, not the .slnx):
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: PromptFiles — kinds, defaults, pure renderer
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/PromptFiles.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Data.Tests/PromptFilesTests.cs` (create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for the pure renderer**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Data.Tests/PromptFilesTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests;
|
||||||
|
|
||||||
|
public class PromptFilesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RenderTemplate_replaces_known_tokens()
|
||||||
|
{
|
||||||
|
var outp = PromptFiles.RenderTemplate(
|
||||||
|
"Plan for {date}, cap {maxTasks}.",
|
||||||
|
new Dictionary<string, string> { ["date"] = "2026-06-04", ["maxTasks"] = "5" });
|
||||||
|
Assert.Equal("Plan for 2026-06-04, cap 5.", outp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderTemplate_leaves_unknown_braces_intact()
|
||||||
|
{
|
||||||
|
var outp = PromptFiles.RenderTemplate(
|
||||||
|
"## {Wochentag}, {dd.MM.yyyy} — {start}",
|
||||||
|
new Dictionary<string, string> { ["start"] = "01.06.2026" });
|
||||||
|
Assert.Equal("## {Wochentag}, {dd.MM.yyyy} — 01.06.2026", outp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultFor_system_mentions_blocked_marker_and_scope()
|
||||||
|
{
|
||||||
|
var d = PromptFiles.DefaultFor(PromptKind.System);
|
||||||
|
Assert.Contains("CLAUDEDO_BLOCKED:", d);
|
||||||
|
Assert.Contains("unattended", d, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultFor_planning_initial_has_title_and_description_tokens()
|
||||||
|
{
|
||||||
|
var d = PromptFiles.DefaultFor(PromptKind.PlanningInitial);
|
||||||
|
Assert.Contains("{title}", d);
|
||||||
|
Assert.Contains("{description}", d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PathFor_planning_is_planning_system_file()
|
||||||
|
{
|
||||||
|
Assert.EndsWith("planning-system.md", PromptFiles.PathFor(PromptKind.Planning));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release`
|
||||||
|
Expected: FAIL — `RenderTemplate`/`DefaultFor` don't exist, `PromptKind.PlanningInitial` undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite PromptFiles.cs**
|
||||||
|
|
||||||
|
Replace the entire contents of `src/ClaudeDo.Data/PromptFiles.cs` with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data;
|
||||||
|
|
||||||
|
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport }
|
||||||
|
|
||||||
|
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-system.md"),
|
||||||
|
PromptKind.PlanningInitial => Path.Combine(Root, "planning-initial.md"),
|
||||||
|
PromptKind.Retry => Path.Combine(Root, "retry.md"),
|
||||||
|
PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"),
|
||||||
|
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>File content if present and non-empty, otherwise the bundled default.</summary>
|
||||||
|
public static string ReadOrDefault(PromptKind kind) => ReadOrNull(kind) ?? DefaultFor(kind);
|
||||||
|
|
||||||
|
/// <summary>Render a prompt: read file-or-default, then substitute named tokens.</summary>
|
||||||
|
public static string Render(PromptKind kind, IReadOnlyDictionary<string, string> values)
|
||||||
|
=> RenderTemplate(ReadOrDefault(kind), values);
|
||||||
|
|
||||||
|
/// <summary>Replace only the given {name} tokens; any other braces pass through untouched.</summary>
|
||||||
|
public static string RenderTemplate(string template, IReadOnlyDictionary<string, string> values)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(template);
|
||||||
|
foreach (var (key, val) in values)
|
||||||
|
sb.Replace("{" + key + "}", val);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string DefaultFor(PromptKind kind) => kind switch
|
||||||
|
{
|
||||||
|
PromptKind.System => SystemDefault,
|
||||||
|
PromptKind.Planning => PlanningSystemDefault,
|
||||||
|
PromptKind.PlanningInitial => PlanningInitialDefault,
|
||||||
|
PromptKind.Retry => RetryDefault,
|
||||||
|
PromptKind.DailyPrep => DailyPrepDefault,
|
||||||
|
PromptKind.WeeklyReport => WeeklyReportDefault,
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string SystemDefault = """
|
||||||
|
# Working Agreement
|
||||||
|
|
||||||
|
You are completing one well-defined task autonomously in a git repository.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Do exactly what the task asks — no unrequested refactors, renames, dependency
|
||||||
|
changes, or "while I'm here" cleanup.
|
||||||
|
- If intent is ambiguous, state the assumption you're making and proceed with the
|
||||||
|
most reasonable reading. Stop only if you genuinely cannot move forward.
|
||||||
|
- Prefer three similar lines over a premature abstraction. Don't build for
|
||||||
|
hypothetical future needs.
|
||||||
|
|
||||||
|
## Working in the repo
|
||||||
|
- Read a file before editing it. Match the conventions already in this codebase —
|
||||||
|
they override generic defaults.
|
||||||
|
- Prefer editing existing files to creating new ones. Don't write comments that
|
||||||
|
just restate the code.
|
||||||
|
- Validate only at real boundaries (user input, external APIs).
|
||||||
|
|
||||||
|
## Finishing
|
||||||
|
- Before claiming done, verify: run the build and relevant tests, confirm they
|
||||||
|
pass, and report what you ran. If you couldn't verify something, say so plainly.
|
||||||
|
- Make focused commits using the repository's existing commit-message convention.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
- Never force-push, hard-reset, or delete branches/files beyond the task's scope
|
||||||
|
without being asked.
|
||||||
|
- Don't introduce injection/XSS/secret-leak issues. Never commit credentials.
|
||||||
|
|
||||||
|
## You are running unattended
|
||||||
|
You run autonomously with no human watching. There is no one to answer mid-task
|
||||||
|
questions, so never stop to ask — make the most reasonable decision, note the
|
||||||
|
assumption, and continue.
|
||||||
|
|
||||||
|
## When you are blocked
|
||||||
|
If something genuinely prevents you from completing part of the task (missing
|
||||||
|
credentials, contradictory requirements, a destructive action you won't take
|
||||||
|
unasked), do NOT silently give up. Write this marker on its own line, then keep
|
||||||
|
working on whatever else you can:
|
||||||
|
|
||||||
|
CLAUDEDO_BLOCKED: <one short sentence describing what blocked you>
|
||||||
|
|
||||||
|
Emit it as many times as needed — once per distinct blocker. Use it only for true
|
||||||
|
blockers, not for routine decisions you can make yourself.
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string PlanningSystemDefault = """
|
||||||
|
You are the planning assistant for ClaudeDo. Your job is to break a task into
|
||||||
|
smaller, independently executable subtasks — the session ends by creating those
|
||||||
|
subtasks.
|
||||||
|
|
||||||
|
Start every session by invoking the `superpowers:brainstorming` skill (Skill
|
||||||
|
tool) and follow it end to end: clarifying questions one at a time, then 2–3
|
||||||
|
approaches with a recommendation, then a short design. Do not create any subtasks
|
||||||
|
until the user has approved the design.
|
||||||
|
|
||||||
|
You can ONLY shape this task's plan — you cannot edit files or touch other tasks.
|
||||||
|
The tools available to you are: CreateChildTask, ListChildTasks, UpdateChildTask,
|
||||||
|
DeleteChildTask, UpdatePlanningTask, and Finalize. Use nothing else.
|
||||||
|
|
||||||
|
Once the design is approved, create the child tasks with CreateChildTask, then
|
||||||
|
call Finalize. Keep each subtask concrete and self-contained with a clear
|
||||||
|
done-state, ordered so dependencies come first.
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string PlanningInitialDefault = """
|
||||||
|
# Task to plan: {title}
|
||||||
|
|
||||||
|
{description}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string RetryDefault = """
|
||||||
|
The task did not complete on the previous attempt — you may have run out of
|
||||||
|
turns, hit an error, or stopped before finishing.
|
||||||
|
|
||||||
|
Review the work already done in this session and the current state of the
|
||||||
|
repository, identify what is still incomplete or broken, and finish the task.
|
||||||
|
Don't restart from scratch or repeat a failed approach. Verify the result
|
||||||
|
(build + tests) before you stop.
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string DailyPrepDefault = """
|
||||||
|
You are preparing my workday for {date}.
|
||||||
|
|
||||||
|
1. Call mcp__claudedo__get_daily_prep_candidates.
|
||||||
|
2. Keep tasks already marked MyDay (currentMyDay) — never remove them.
|
||||||
|
3. Fill MyDay to at most {maxTasks} open tasks TOTAL (currentMyDay counts). Never exceed it.
|
||||||
|
4. Estimate each candidate's effort and pick a feasible mix — not only big items.
|
||||||
|
Prioritize isStarred, due (scheduledFor), and older tasks.
|
||||||
|
5. Place related tasks next to each other using consecutive sortOrder values.
|
||||||
|
6. Apply via mcp__claudedo__set_my_day(taskId, true, sortOrder). Never mark anything
|
||||||
|
outside the candidate list.
|
||||||
|
|
||||||
|
If there are no candidates, do nothing.
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string WeeklyReportDefault = """
|
||||||
|
You are generating a concise weekly standup report for a software developer,
|
||||||
|
covering {start} to {end}.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Write the ENTIRE report in German.
|
||||||
|
- Group by day. One "## {Wochentag}, {dd.MM.yyyy}" section per day that has
|
||||||
|
activity (German weekday names). Omit days with no activity.
|
||||||
|
- Within each day: 3–5 first-person, past-tense bullets ("- Habe X umgesetzt",
|
||||||
|
"- Y behoben"). Merge related small work into one bullet.
|
||||||
|
- Drop trivia: typo fixes, pure exploration, false starts, tooling/log noise.
|
||||||
|
- Blend the developer's own notes and the derived activity into ONE deduplicated
|
||||||
|
bullet list per day. The notes are authoritative — never omit or contradict them.
|
||||||
|
- Name the project/repo when it adds clarity.
|
||||||
|
- Output ONLY the dated sections. No preamble, no intro, no closing remarks.
|
||||||
|
|
||||||
|
Two sections follow below: an activity log derived from Claude session history,
|
||||||
|
and the developer's own notes. Base the report on both; the notes are
|
||||||
|
authoritative where they conflict with the derived activity.
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release`
|
||||||
|
Expected: PASS (5 new tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs
|
||||||
|
git commit -m "feat(prompts): externalize prompt kinds with defaults and token renderer"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: TaskRunner — drop agent file from system prompt merge
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:382-386`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove the agent-file read and merge**
|
||||||
|
|
||||||
|
In `ResolveConfigAsync`, replace:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var systemFile = PromptFiles.ReadOrNull(PromptKind.System);
|
||||||
|
var agentFile = PromptFiles.ReadOrNull(PromptKind.Agent);
|
||||||
|
|
||||||
|
var instructions = MergeInstructions(
|
||||||
|
systemFile, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt, agentFile);
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var systemFile = PromptFiles.ReadOrNull(PromptKind.System);
|
||||||
|
|
||||||
|
var instructions = MergeInstructions(
|
||||||
|
systemFile, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build to verify it compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||||
|
Expected: PASS (no reference to `PromptKind.Agent` remains).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
|
||||||
|
git commit -m "refactor(prompts): collapse agent prompt into system prompt"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Retry prompt from file + conditional stderr append
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs:101-103` (expose prefix const)
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` (add `BuildRetryPrompt`, use it at ~L107)
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Runner/RetryPromptTests.cs` (create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for the retry-prompt helper**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/Runner/RetryPromptTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Runner;
|
||||||
|
|
||||||
|
public class RetryPromptTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Generic_no_result_error_is_not_appended()
|
||||||
|
{
|
||||||
|
var prompt = TaskRunner.BuildRetryPrompt($"{ClaudeProcess.NoResultPrefix} 1 and no result.");
|
||||||
|
Assert.DoesNotContain("Captured error", prompt);
|
||||||
|
Assert.Contains("did not complete", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Real_error_is_appended()
|
||||||
|
{
|
||||||
|
var prompt = TaskRunner.BuildRetryPrompt("error CS1002: ; expected");
|
||||||
|
Assert.Contains("Captured error", prompt);
|
||||||
|
Assert.Contains("CS1002", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_error_yields_bare_prompt()
|
||||||
|
{
|
||||||
|
var prompt = TaskRunner.BuildRetryPrompt(null);
|
||||||
|
Assert.DoesNotContain("Captured error", prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RetryPromptTests`
|
||||||
|
Expected: FAIL — `BuildRetryPrompt` / `NoResultPrefix` don't exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Expose the no-result prefix in ClaudeProcess**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs`, add the const near the top of the class and use it in the error fallback. Replace:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var error = lastStderr.Length > 0
|
||||||
|
? lastStderr.ToString().Trim()
|
||||||
|
: $"Claude exited with code {exitCode} and no result.";
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var error = lastStderr.Length > 0
|
||||||
|
? lastStderr.ToString().Trim()
|
||||||
|
: $"{NoResultPrefix} {exitCode} and no result.";
|
||||||
|
```
|
||||||
|
|
||||||
|
and add inside the class (e.g. just below the fields):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public const string NoResultPrefix = "Claude exited with code";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add BuildRetryPrompt to TaskRunner and use it**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, add this static method (next to `MergeInstructions`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static string BuildRetryPrompt(string? capturedError)
|
||||||
|
{
|
||||||
|
var basePrompt = PromptFiles.ReadOrDefault(PromptKind.Retry);
|
||||||
|
var isReal = !string.IsNullOrWhiteSpace(capturedError)
|
||||||
|
&& !capturedError!.StartsWith(ClaudeProcess.NoResultPrefix, StringComparison.Ordinal);
|
||||||
|
return isReal
|
||||||
|
? $"{basePrompt}\n\nCaptured error from the failed run:\n\n{capturedError!.Trim()}"
|
||||||
|
: basePrompt;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then replace the inline retry prompt at ~L107:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var retryPrompt = $"The previous attempt failed with:\n\n{result.ErrorMarkdown}\n\nTry again and fix the issues.";
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var retryPrompt = BuildRetryPrompt(result.ErrorMarkdown);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RetryPromptTests`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Runner/ClaudeProcess.cs src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/RetryPromptTests.cs
|
||||||
|
git commit -m "feat(prompts): retry prompt from file, append only real captured errors"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: PlanningSessionManager reads planning prompts from files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (`BuildSystemPrompt` ~L366, `BuildInitialPrompt` ~L392)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace BuildSystemPrompt body**
|
||||||
|
|
||||||
|
Replace the whole method body of `BuildSystemPrompt()` with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static string BuildSystemPrompt() => PromptFiles.ReadOrDefault(PromptKind.Planning);
|
||||||
|
```
|
||||||
|
|
||||||
|
(Delete the inline fallback string literal that followed.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace BuildInitialPrompt body**
|
||||||
|
|
||||||
|
Replace the whole method body of `BuildInitialPrompt(TaskEntity task)` with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static string BuildInitialPrompt(TaskEntity task) =>
|
||||||
|
PromptFiles.Render(PromptKind.PlanningInitial, new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["title"] = task.Title,
|
||||||
|
["description"] = task.Description ?? "",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure `using ClaudeDo.Data;` is present (it is — `PromptFiles` lived there already via `ReadOrNull`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build to verify it compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
|
||||||
|
git commit -m "refactor(prompts): planning prompts read from editable files"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: DailyPrepPrompt reads from file
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs`
|
||||||
|
- Modify: `tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update DailyPrepPromptTests to assert the English default render**
|
||||||
|
|
||||||
|
Replace the `Build_prompt_contains_cap_and_date` test body with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void Build_prompt_contains_cap_and_date()
|
||||||
|
{
|
||||||
|
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks: 5, today: new DateOnly(2026, 6, 3));
|
||||||
|
Assert.Contains("5", prompt);
|
||||||
|
Assert.Contains("2026-06-03", prompt);
|
||||||
|
Assert.Contains("get_daily_prep_candidates", prompt);
|
||||||
|
Assert.Contains("set_my_day", prompt);
|
||||||
|
Assert.Contains("preparing my workday", prompt);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(The new assertion pins the English default; the file-read path is exercised by the same default when no `daily-prep.md` exists.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DailyPrepPromptTests`
|
||||||
|
Expected: FAIL — current German prompt has no "preparing my workday".
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite BuildPrompt to read the file**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs`, replace the `BuildPrompt` method with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static string BuildPrompt(int maxTasks, DateOnly today) =>
|
||||||
|
ClaudeDo.Data.PromptFiles.Render(
|
||||||
|
ClaudeDo.Data.PromptKind.DailyPrep,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["date"] = today.ToString("yyyy-MM-dd"),
|
||||||
|
["maxTasks"] = maxTasks.ToString(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave `BuildArgs`, `LogPath`, and the tool-name consts unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DailyPrepPromptTests`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs
|
||||||
|
git commit -m "feat(prompts): daily-prep prompt from file, English default"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: WeekReportPromptBuilder reads instructions from file
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs`
|
||||||
|
- Check: `tests/ClaudeDo.Worker.Tests/Report/WeekReportPromptBuilderTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the inline Instructions with a file read**
|
||||||
|
|
||||||
|
In `WeekReportPromptBuilder.Build`, replace:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, Instructions,
|
||||||
|
start.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
|
||||||
|
end.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)));
|
||||||
|
sb.AppendLine();
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine(ClaudeDo.Data.PromptFiles.Render(
|
||||||
|
ClaudeDo.Data.PromptKind.WeeklyReport,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["start"] = start.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
|
||||||
|
["end"] = end.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
|
||||||
|
}));
|
||||||
|
sb.AppendLine();
|
||||||
|
```
|
||||||
|
|
||||||
|
Then delete the now-unused `private const string Instructions = ...` block. (The `{Wochentag}`/`{dd.MM.yyyy}` literals inside the default survive because `RenderTemplate` only replaces `{start}`/`{end}`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the existing builder test still passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter WeekReportPromptBuilderTests`
|
||||||
|
Expected: PASS. If a test asserted exact old wording, update it to assert the date appears and that activity/notes sections render (the new default keeps German output rules).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs tests/ClaudeDo.Worker.Tests/Report/WeekReportPromptBuilderTests.cs
|
||||||
|
git commit -m "feat(prompts): weekly-report instructions from file, point at data sections"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: StreamAnalyzer collects roadblock markers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
Append to `StreamAnalyzerTests`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void Collects_Blocked_Markers_From_Assistant_Text()
|
||||||
|
{
|
||||||
|
var analyzer = new StreamAnalyzer();
|
||||||
|
analyzer.ProcessLine("""{"type":"assistant","message":{"content":[{"type":"text","text":"working\nCLAUDEDO_BLOCKED: missing API key\nmoving on"}]}}""");
|
||||||
|
analyzer.ProcessLine("""{"type":"assistant","message":{"content":[{"type":"text","text":"CLAUDEDO_BLOCKED: cannot reach db"}]}}""");
|
||||||
|
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
|
||||||
|
var result = analyzer.GetResult();
|
||||||
|
Assert.Equal(2, result.Blocks.Count);
|
||||||
|
Assert.Equal("missing API key", result.Blocks[0]);
|
||||||
|
Assert.Equal("cannot reach db", result.Blocks[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Strips_Blocked_Markers_From_Result_Text()
|
||||||
|
{
|
||||||
|
var analyzer = new StreamAnalyzer();
|
||||||
|
analyzer.ProcessLine("""{"type":"result","result":"All set.\nCLAUDEDO_BLOCKED: no creds\nDone.","session_id":"s1"}""");
|
||||||
|
var result = analyzer.GetResult();
|
||||||
|
Assert.DoesNotContain("CLAUDEDO_BLOCKED", result.ResultMarkdown);
|
||||||
|
Assert.Single(result.Blocks);
|
||||||
|
Assert.Equal("no creds", result.Blocks[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_Markers_Means_Empty_Blocks()
|
||||||
|
{
|
||||||
|
var analyzer = new StreamAnalyzer();
|
||||||
|
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
|
||||||
|
Assert.Empty(analyzer.GetResult().Blocks);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter StreamAnalyzerTests`
|
||||||
|
Expected: FAIL — `Blocks` doesn't exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement marker collection in StreamAnalyzer**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs`:
|
||||||
|
|
||||||
|
Add to `StreamResult`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public IReadOnlyList<string> Blocks { get; set; } = Array.Empty<string>();
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a field and a constant to `StreamAnalyzer`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private readonly List<string> _blocks = new();
|
||||||
|
private const string BlockedPrefix = "CLAUDEDO_BLOCKED:";
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `case "result":` branch, after `_resultMarkdown` is assigned, scan and strip:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (root.TryGetProperty("result", out var resultProp))
|
||||||
|
_resultMarkdown = StripAndCollect(resultProp.GetString());
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `case "assistant":` branch, collect from text content (keep `_turnCount++`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
case "assistant":
|
||||||
|
_turnCount++;
|
||||||
|
CollectFromAssistant(root);
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add these helpers to the class:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void CollectFromAssistant(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("message", out var msg)) return;
|
||||||
|
if (!msg.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) return;
|
||||||
|
foreach (var block in content.EnumerateArray())
|
||||||
|
if (block.TryGetProperty("type", out var t) && t.GetString() == "text"
|
||||||
|
&& block.TryGetProperty("text", out var txt))
|
||||||
|
ScanForBlocks(txt.GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ScanForBlocks(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return;
|
||||||
|
foreach (var line in text.Split('\n'))
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (trimmed.StartsWith(BlockedPrefix, StringComparison.Ordinal))
|
||||||
|
_blocks.Add(trimmed[BlockedPrefix.Length..].Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? StripAndCollect(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return text;
|
||||||
|
ScanForBlocks(text);
|
||||||
|
var kept = text.Split('\n')
|
||||||
|
.Where(l => !l.Trim().StartsWith(BlockedPrefix, StringComparison.Ordinal));
|
||||||
|
return string.Join('\n', kept).Trim();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `Blocks = _blocks` to the `GetResult()` initializer:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public StreamResult GetResult() => new()
|
||||||
|
{
|
||||||
|
ResultMarkdown = FallbackResult(),
|
||||||
|
StructuredOutputJson = _structuredOutputJson,
|
||||||
|
SessionId = _sessionId,
|
||||||
|
TurnCount = _turnCount,
|
||||||
|
TokensIn = _tokensIn,
|
||||||
|
TokensOut = _tokensOut,
|
||||||
|
ApiRetryCount = _apiRetryCount,
|
||||||
|
Blocks = _blocks,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter StreamAnalyzerTests`
|
||||||
|
Expected: PASS (all old + 3 new).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs
|
||||||
|
git commit -m "feat(roadblock): collect and strip CLAUDEDO_BLOCKED markers in StreamAnalyzer"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: RunResult + ClaudeProcess carry Blocks
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/RunResult.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs:89-113`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add Blocks to RunResult**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Runner/RunResult.cs`, add inside the class:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public IReadOnlyList<string> Blocks { get; init; } = Array.Empty<string>();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Populate Blocks in both RunResult returns**
|
||||||
|
|
||||||
|
In `ClaudeProcess.RunAsync`, add `Blocks = streamResult.Blocks,` to **both** the success `RunResult { ... }` (after `TokensOut`) and the error `RunResult { ... }` initializer.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build to verify it compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Runner/RunResult.cs src/ClaudeDo.Worker/Runner/ClaudeProcess.cs
|
||||||
|
git commit -m "feat(roadblock): carry blocks through RunResult"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Fold roadblocks into the review result
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` (`HandleSuccess` ~L319-352; add `ComposeReviewResult`)
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Runner/ReviewResultTests.cs` (create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for the compose helper**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/Runner/ReviewResultTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Runner;
|
||||||
|
|
||||||
|
public class ReviewResultTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void No_blocks_returns_result_unchanged()
|
||||||
|
{
|
||||||
|
Assert.Equal("done", TaskRunner.ComposeReviewResult("done", Array.Empty<string>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Blocks_are_appended_as_a_section()
|
||||||
|
{
|
||||||
|
var outp = TaskRunner.ComposeReviewResult("done", new[] { "no creds", "db down" });
|
||||||
|
Assert.Contains("⚠ Roadblocks", outp);
|
||||||
|
Assert.Contains("- no creds", outp);
|
||||||
|
Assert.Contains("- db down", outp);
|
||||||
|
Assert.Contains("done", outp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_result_with_blocks_still_lists_them()
|
||||||
|
{
|
||||||
|
var outp = TaskRunner.ComposeReviewResult(null, new[] { "x" });
|
||||||
|
Assert.Contains("⚠ Roadblocks", outp);
|
||||||
|
Assert.Contains("- x", outp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter ReviewResultTests`
|
||||||
|
Expected: FAIL — `ComposeReviewResult` doesn't exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add ComposeReviewResult and use it in HandleSuccess**
|
||||||
|
|
||||||
|
In `TaskRunner`, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static string? ComposeReviewResult(string? result, IReadOnlyList<string> blocks)
|
||||||
|
{
|
||||||
|
if (blocks.Count == 0) return result;
|
||||||
|
var section = "⚠ Roadblocks reported during the run:\n"
|
||||||
|
+ string.Join('\n', blocks.Select(b => $"- {b}"));
|
||||||
|
return string.IsNullOrWhiteSpace(result) ? section : $"{result}\n\n{section}";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `HandleSuccess`, compute the composed result once and pass it to both terminal writes:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var finishedAt = DateTime.UtcNow;
|
||||||
|
var reviewResult = ComposeReviewResult(result.ResultMarkdown, result.Blocks);
|
||||||
|
if (task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None)
|
||||||
|
{
|
||||||
|
await _state.SubmitForReviewAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
|
||||||
|
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||||
|
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _state.CompleteAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
|
||||||
|
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||||
|
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Make sure `using System.Linq;` is available — it is, via implicit usings.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter ReviewResultTests`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/ReviewResultTests.cs
|
||||||
|
git commit -m "feat(roadblock): surface reported roadblocks in the review result"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Files-settings UI exposes the new prompt files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs`
|
||||||
|
- Modify: the Files settings view (find with: `Grep "SystemPromptPath" src/ClaudeDo.Ui` → the `.axaml` binding to `OpenPromptCommand`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the prompt-path properties**
|
||||||
|
|
||||||
|
In `FilesSettingsTabViewModel`, replace the three path properties with the new set (drop Agent, add the rest):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System);
|
||||||
|
public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning);
|
||||||
|
public string PlanningInitialPromptPath { get; } = PromptFiles.PathFor(PromptKind.PlanningInitial);
|
||||||
|
public string RetryPromptPath { get; } = PromptFiles.PathFor(PromptKind.Retry);
|
||||||
|
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
|
||||||
|
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
|
||||||
|
```
|
||||||
|
|
||||||
|
(`OpenPromptCommand` already parses the `PromptKind` name from its parameter, so no command change is needed.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the view**
|
||||||
|
|
||||||
|
Open the Files settings `.axaml`. For the existing System/Planning/Agent rows: keep System, keep Planning, **remove the Agent row**, and add four rows mirroring the System row's markup — each binding its label/path to the new property and passing the matching `PromptKind` name as the `OpenPromptCommand` parameter:
|
||||||
|
|
||||||
|
- `Planning` (system) → "Planning system prompt", `PlanningPromptPath`, parameter `Planning`
|
||||||
|
- `PlanningInitial` → "Planning kickoff prompt", `PlanningInitialPromptPath`, parameter `PlanningInitial`
|
||||||
|
- `Retry` → "Retry prompt", `RetryPromptPath`, parameter `Retry`
|
||||||
|
- `DailyPrep` → "Daily-prep prompt", `DailyPrepPromptPath`, parameter `DailyPrep`
|
||||||
|
- `WeeklyReport` → "Weekly-report prompt", `WeeklyReportPromptPath`, parameter `WeeklyReport`
|
||||||
|
|
||||||
|
Use the exact same control template as the existing System row (same button + `CommandParameter` shape); only the bound property, label text, and parameter string differ.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the UI project**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Visual check (manual — flag for user)**
|
||||||
|
|
||||||
|
Start the app, open Settings → Files tab. Confirm six "Open" prompt buttons appear (System, Planning system, Planning kickoff, Retry, Daily-prep, Weekly-report), no Agent row, and each opens/seeds the right file under `~/.todo-app/prompts/`. **This step cannot be verified by the agent — ask the user to confirm visually.**
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs src/ClaudeDo.Ui/Views/**/*Files*.axaml
|
||||||
|
git commit -m "feat(ui): expose all editable prompt files, drop agent prompt"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: Full build + test sweep
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build worker + app**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run all affected test projects**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update docs**
|
||||||
|
|
||||||
|
Update `docs/prompts-inventory.md` to note the externalized files and that `agent.md`/`planning.md` are retired in favor of `system.md`/`planning-system.md`. Note `CLAUDEDO_BLOCKED:` in the inventory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/prompts-inventory.md
|
||||||
|
git commit -m "docs: refresh prompt inventory for externalized prompts + roadblock marker"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- **Spec coverage:** system.md collapse (T2), planning prompts (T4), retry (T3), daily-prep English (T5), weekly-report + data pointer (T6), templating/`Render` (T1), roadblock detect/strip/route (T7–T9), file layout + migration via `EnsureExists`/new `PathFor` (T1), UI surface (T10). The "Out-of-scope improvements" system.md section is intentionally **deferred to the child-tasks plan** (it depends on the `SuggestImprovement` tool).
|
||||||
|
- **Migration:** old `planning.md`/`agent.md` go inert automatically — `TaskRunner` no longer reads agent (T2), planning now reads `planning-system.md` (T1 PathFor). No code deletes the old files; harmless.
|
||||||
|
- **Determinism:** content tests target `DefaultFor`/`RenderTemplate` (pure, no disk). Consumers fall back to the same default when no user file exists.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,725 @@
|
|||||||
|
# Debug Logging & Frontend↔Backend Traceability 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:** Build-configuration-driven logging — verbose in Debug builds (Rider run button), minimal `Warning`+ in Release (installed app) — with both processes writing one shared `claudedo-.log` and a `TaskId` correlation key threading UI→Worker→UI.
|
||||||
|
|
||||||
|
**Architecture:** A new `ClaudeDo.Logging` library owns all Serilog setup: a `BuildConfig.IsDebug` runtime check (via the entry assembly's `DebuggableAttribute`, no `#if DEBUG`), a default-`TaskId` enricher, and a `LoggingSetup.Configure` method that branches sinks/levels on `IsDebug`. Worker and App both call it. `TaskId` rides Serilog `LogContext`, pushed at the per-task entry points on each side.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, Serilog (core + File + Console sinks), Serilog.Extensions.Logging (App bridge), Serilog.AspNetCore (Worker, already present), xUnit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create the `ClaudeDo.Logging` project
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Logging/ClaudeDo.Logging.csproj`
|
||||||
|
- Create: `src/ClaudeDo.Logging/Placeholder.cs` (temporary, removed in Task 2)
|
||||||
|
- Modify: `ClaudeDo.slnx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the csproj**
|
||||||
|
|
||||||
|
Create `src/ClaudeDo.Logging/ClaudeDo.Logging.csproj`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Serilog" Version="4.1.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
> If NuGet reports a version conflict between `Serilog 4.1.0` and the `Serilog` core pulled transitively by `Serilog.AspNetCore 8.0.3` (Worker), align this `Serilog` version to whatever `Serilog.AspNetCore 8.0.3` resolves (check `dotnet list package --include-transitive`) and rebuild.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a temporary placeholder so the project compiles**
|
||||||
|
|
||||||
|
Create `src/ClaudeDo.Logging/Placeholder.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Logging;
|
||||||
|
|
||||||
|
internal static class Placeholder;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Register the project in the solution**
|
||||||
|
|
||||||
|
Edit `ClaudeDo.slnx` — add inside the `/src/` folder, after the `ClaudeDo.Localization` line:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project Path="src/ClaudeDo.Logging/ClaudeDo.Logging.csproj" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the new project**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Logging/ClaudeDo.Logging.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Logging/ClaudeDo.Logging.csproj src/ClaudeDo.Logging/Placeholder.cs ClaudeDo.slnx
|
||||||
|
git commit -m "build(logging): scaffold ClaudeDo.Logging project"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `DefaultTaskIdEnricher` (TDD)
|
||||||
|
|
||||||
|
Adds `TaskId = "-"` to any log event that doesn't already carry a `TaskId` property, so the `[{TaskId}]` column never renders the raw token. A pushed `LogContext` value takes precedence (because `Enrich.FromLogContext()` runs first and the property is then already present).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs`
|
||||||
|
- Delete: `src/ClaudeDo.Logging/Placeholder.cs`
|
||||||
|
- Create: `tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs`
|
||||||
|
- Modify: `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` (add project reference)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Reference `ClaudeDo.Logging` from the test project**
|
||||||
|
|
||||||
|
Edit `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — add to the existing `ProjectReference` ItemGroup:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ProjectReference Include="..\..\src\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Logging;
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Context;
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Logging;
|
||||||
|
|
||||||
|
public sealed class DefaultTaskIdEnricherTests
|
||||||
|
{
|
||||||
|
private sealed class CollectingSink : ILogEventSink
|
||||||
|
{
|
||||||
|
public List<LogEvent> Events { get; } = new();
|
||||||
|
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddsDash_WhenNoTaskIdInScope()
|
||||||
|
{
|
||||||
|
var sink = new CollectingSink();
|
||||||
|
using var logger = new LoggerConfiguration()
|
||||||
|
.Enrich.FromLogContext()
|
||||||
|
.Enrich.With(new DefaultTaskIdEnricher())
|
||||||
|
.WriteTo.Sink(sink)
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
|
logger.Information("hello");
|
||||||
|
|
||||||
|
var prop = Assert.Single(sink.Events).Properties["TaskId"];
|
||||||
|
Assert.Equal("\"-\"", prop.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KeepsPushedTaskId_WhenInScope()
|
||||||
|
{
|
||||||
|
var sink = new CollectingSink();
|
||||||
|
using var logger = new LoggerConfiguration()
|
||||||
|
.Enrich.FromLogContext()
|
||||||
|
.Enrich.With(new DefaultTaskIdEnricher())
|
||||||
|
.WriteTo.Sink(sink)
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
|
using (LogContext.PushProperty("TaskId", "task-42"))
|
||||||
|
logger.Information("hello");
|
||||||
|
|
||||||
|
var prop = Assert.Single(sink.Events).Properties["TaskId"];
|
||||||
|
Assert.Equal("\"task-42\"", prop.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DefaultTaskIdEnricherTests`
|
||||||
|
Expected: FAIL — `DefaultTaskIdEnricher` does not exist (compile error).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement the enricher and remove the placeholder**
|
||||||
|
|
||||||
|
Delete `src/ClaudeDo.Logging/Placeholder.cs`.
|
||||||
|
|
||||||
|
Create `src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Logging;
|
||||||
|
|
||||||
|
/// <summary>Ensures every log event carries a TaskId property (defaulting to "-")
|
||||||
|
/// so the output template's [{TaskId}] column never renders the raw token.</summary>
|
||||||
|
public sealed class DefaultTaskIdEnricher : ILogEventEnricher
|
||||||
|
{
|
||||||
|
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
|
||||||
|
{
|
||||||
|
if (!logEvent.Properties.ContainsKey("TaskId"))
|
||||||
|
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TaskId", "-"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DefaultTaskIdEnricherTests`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
|
||||||
|
git rm src/ClaudeDo.Logging/Placeholder.cs
|
||||||
|
git commit -m "feat(logging): default TaskId enricher with passing tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `BuildConfig.IsDebug`
|
||||||
|
|
||||||
|
Detects whether the entry assembly was compiled in the Debug configuration (JIT optimizer disabled) — the runtime replacement for `#if DEBUG`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Logging/BuildConfig.cs`
|
||||||
|
- Create: `tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
The test asserts the property returns *some* bool without throwing, and that the underlying detection logic agrees with the test assembly's own `DebuggableAttribute` (the test runs under whatever config `dotnet test` used). We assert the helper's result equals a locally-computed expectation so it passes under both Debug and Release test runs.
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
using ClaudeDo.Logging;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Logging;
|
||||||
|
|
||||||
|
public sealed class BuildConfigTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void IsDebug_MatchesEntryAssemblyDebuggableAttribute()
|
||||||
|
{
|
||||||
|
var entry = Assembly.GetEntryAssembly();
|
||||||
|
var expected = entry?
|
||||||
|
.GetCustomAttribute<DebuggableAttribute>()
|
||||||
|
?.IsJITOptimizerDisabled ?? false;
|
||||||
|
|
||||||
|
Assert.Equal(expected, BuildConfig.IsDebug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter BuildConfigTests`
|
||||||
|
Expected: FAIL — `BuildConfig` does not exist (compile error).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `BuildConfig`**
|
||||||
|
|
||||||
|
Create `src/ClaudeDo.Logging/BuildConfig.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Logging;
|
||||||
|
|
||||||
|
/// <summary>Runtime build-configuration detection — the replacement for #if DEBUG.
|
||||||
|
/// Debug builds compile with the JIT optimizer disabled; Release builds enable it.</summary>
|
||||||
|
public static class BuildConfig
|
||||||
|
{
|
||||||
|
public static bool IsDebug { get; } =
|
||||||
|
Assembly.GetEntryAssembly()
|
||||||
|
?.GetCustomAttribute<DebuggableAttribute>()
|
||||||
|
?.IsJITOptimizerDisabled ?? false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter BuildConfigTests`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Logging/BuildConfig.cs tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs
|
||||||
|
git commit -m "feat(logging): runtime Debug-build detection via DebuggableAttribute"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: `LoggingSetup.Configure`
|
||||||
|
|
||||||
|
The single shared configuration entry point. Applies enrichers, the output template, and branches sinks/levels on `BuildConfig.IsDebug`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Logging/LoggingSetup.cs`
|
||||||
|
- Create: `tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Verifies a configured logger actually writes a `Warning` (emitted in both build configs) to a `claudedo-*.log` file under the given log root.
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Logging;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Logging;
|
||||||
|
|
||||||
|
public sealed class LoggingSetupTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Configure_WritesSharedLogFile()
|
||||||
|
{
|
||||||
|
var logRoot = Path.Combine(Path.GetTempPath(), "claudedo-logtest-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(logRoot);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logger = LoggingSetup.Configure(new LoggerConfiguration(), "test", logRoot).CreateLogger();
|
||||||
|
logger.Warning("marker-{Marker}", "xyz");
|
||||||
|
logger.Dispose(); // flush + release the file handle
|
||||||
|
|
||||||
|
var files = Directory.GetFiles(logRoot, "claudedo-*.log");
|
||||||
|
var file = Assert.Single(files);
|
||||||
|
var contents = File.ReadAllText(file);
|
||||||
|
Assert.Contains("marker-", contents);
|
||||||
|
Assert.Contains("test/", contents); // {Process} tag in the template
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(logRoot, recursive: true); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter LoggingSetupTests`
|
||||||
|
Expected: FAIL — `LoggingSetup` does not exist (compile error).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `LoggingSetup`**
|
||||||
|
|
||||||
|
Create `src/ClaudeDo.Logging/LoggingSetup.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Logging;
|
||||||
|
|
||||||
|
public static class LoggingSetup
|
||||||
|
{
|
||||||
|
private const string OutputTemplate =
|
||||||
|
"[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Process}/{SourceContext} [{TaskId}] {Message:lj}{NewLine}{Exception}";
|
||||||
|
|
||||||
|
/// <summary>Apply the shared ClaudeDo logging configuration.
|
||||||
|
/// Debug builds: Debug level, console + shared file. Release builds: Warning level, shared file only.</summary>
|
||||||
|
/// <param name="processTag">"worker" or "app" — tags every line so the interleaved file is readable.</param>
|
||||||
|
/// <param name="logRoot">Directory for the shared claudedo-.log (created if missing).</param>
|
||||||
|
public static LoggerConfiguration Configure(LoggerConfiguration cfg, string processTag, string logRoot)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(logRoot);
|
||||||
|
var logFile = Path.Combine(logRoot, "claudedo-.log");
|
||||||
|
|
||||||
|
cfg.Enrich.FromLogContext()
|
||||||
|
.Enrich.WithProperty("Process", processTag)
|
||||||
|
.Enrich.With(new DefaultTaskIdEnricher());
|
||||||
|
|
||||||
|
if (BuildConfig.IsDebug)
|
||||||
|
{
|
||||||
|
cfg.MinimumLevel.Debug()
|
||||||
|
.WriteTo.Console(outputTemplate: OutputTemplate)
|
||||||
|
.WriteTo.File(
|
||||||
|
logFile,
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
retainedFileCountLimit: 2,
|
||||||
|
shared: true,
|
||||||
|
outputTemplate: OutputTemplate);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cfg.MinimumLevel.Warning()
|
||||||
|
.WriteTo.File(
|
||||||
|
logFile,
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
retainedFileCountLimit: 2,
|
||||||
|
shared: true,
|
||||||
|
outputTemplate: OutputTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter LoggingSetupTests`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Logging/LoggingSetup.cs tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs
|
||||||
|
git commit -m "feat(logging): shared LoggingSetup with build-config sink branching"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Wire the Worker to the shared setup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Program.cs:34-40`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the project reference**
|
||||||
|
|
||||||
|
Edit `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — add to the existing `ProjectReference` ItemGroup (the one with `ClaudeDo.Data`):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ProjectReference Include="..\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the inline Serilog config**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Program.cs`, replace lines 34-40:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Host.UseSerilog((ctx, lc) => lc
|
||||||
|
.MinimumLevel.Information()
|
||||||
|
.WriteTo.File(
|
||||||
|
System.IO.Path.Combine(logRoot, "worker-.log"),
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
retainedFileCountLimit: 7,
|
||||||
|
shared: true));
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Host.UseSerilog((ctx, lc) =>
|
||||||
|
ClaudeDo.Logging.LoggingSetup.Configure(lc, "worker", logRoot));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the Worker**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||||
|
Expected: Build succeeded. (If the Worker is running and locks the Debug output, this Release build is unaffected.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/ClaudeDo.Worker.csproj src/ClaudeDo.Worker/Program.cs
|
||||||
|
git commit -m "feat(logging): route Worker logging through shared LoggingSetup"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Wire the App/Ui (currently log-silent) to the shared setup
|
||||||
|
|
||||||
|
The App uses a plain `ServiceCollection` with **no** logging registered. Add the Serilog→`ILogger` bridge so all `ILogger<T>` injections across App/Ui flow to the shared sinks, and flush on shutdown.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||||
|
- Modify: `src/ClaudeDo.App/Program.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add packages and the project reference**
|
||||||
|
|
||||||
|
Edit `src/ClaudeDo.App/ClaudeDo.App.csproj` — add to the package `ItemGroup`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
||||||
|
```
|
||||||
|
|
||||||
|
and to the `ProjectReference` ItemGroup:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ProjectReference Include="..\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the logging registration in `BuildServices`**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.App/Program.cs`, inside `BuildServices()`, immediately after the `var sc = new ServiceCollection();` line (currently line 78), insert:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var logRoot = Path.Combine(Path.GetDirectoryName(dbPath)!, "logs");
|
||||||
|
var serilogLogger = ClaudeDo.Logging.LoggingSetup
|
||||||
|
.Configure(new Serilog.LoggerConfiguration(), "app", logRoot)
|
||||||
|
.CreateLogger();
|
||||||
|
sc.AddLogging(b => b.AddSerilog(serilogLogger, dispose: true));
|
||||||
|
```
|
||||||
|
|
||||||
|
Add these usings to the top of `Program.cs` (the `AddSerilog` `ILoggingBuilder` extension lives in the `Serilog` namespace; `AddLogging` lives in `Microsoft.Extensions.DependencyInjection`, already imported):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Serilog;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
```
|
||||||
|
|
||||||
|
> `dbPath` is already computed just above (`var dbPath = Paths.Expand(settings.DbPath);`). Its parent directory is `~/.todo-app`, so `logs` sits beside the Worker's log root.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the App**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded (pulls in Ui + Data + Logging).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify manually from Rider (visual-verification gap)**
|
||||||
|
|
||||||
|
This is a Debug-build behavior that cannot be asserted in a Release test run. Launch the App from Rider's run button and confirm:
|
||||||
|
- A `claudedo-*.log` appears in `~/.todo-app/logs/`.
|
||||||
|
- Console output (Rider run window) shows `Debug`-level lines tagged `app/...`.
|
||||||
|
|
||||||
|
Flag to the user that this step needs their eyes.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.App/ClaudeDo.App.csproj src/ClaudeDo.App/Program.cs
|
||||||
|
git commit -m "feat(logging): wire App/Ui logging to shared LoggingSetup"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Push `TaskId` into `LogContext` in the Worker
|
||||||
|
|
||||||
|
Wraps the two per-task entry points so every nested log line (runner, state service, worktree, planning) carries the task's id automatically.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:47` (`RunAsync`) and `:171` (`ContinueAsync`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the using directive**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, add to the top usings:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Serilog.Context;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Push TaskId at the top of `RunAsync`**
|
||||||
|
|
||||||
|
In `RunAsync` (line 47), insert as the very first statement of the method body (before `string? mcpToken = null;`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using var _taskScope = LogContext.PushProperty("TaskId", task.Id);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Push TaskId at the top of `ContinueAsync`**
|
||||||
|
|
||||||
|
In `ContinueAsync` (line 171), insert as the very first statement of the method body (before `TaskEntity task;`). The parameter is `taskId`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using var _taskScope = LogContext.PushProperty("TaskId", taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the Worker**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
|
||||||
|
git commit -m "feat(logging): tag Worker task execution with TaskId for traceability"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Push `TaskId` and add trace lines on the App side
|
||||||
|
|
||||||
|
`WorkerClient` currently logs nothing. Inject `ILogger<WorkerClient>`, add a small helper that pushes `TaskId` + emits a `Debug` trace line, and route the fire-and-forget task-targeted hub calls through it. This produces the UI half of the UI→Worker→UI trace under a shared `TaskId`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
- Modify: `src/ClaudeDo.App/Program.cs:101` (registration)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add usings and the logger field/ctor param**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add to the usings:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Serilog.Context;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a field beside `private readonly HubConnection _hub;` (line 32):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private readonly ILogger<WorkerClient> _logger;
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the constructor signature (line 68) from:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public WorkerClient(string signalRUrl)
|
||||||
|
{
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public WorkerClient(string signalRUrl, ILogger<WorkerClient> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the task-scoped invoke helper**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add this private method next to `TryInvokeAsync` (after line 241):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>Invoke a task-targeted hub method under a TaskId log scope, emitting a debug trace line.</summary>
|
||||||
|
private async Task InvokeForTaskAsync(string taskId, string method, params object?[] args)
|
||||||
|
{
|
||||||
|
using (LogContext.PushProperty("TaskId", taskId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("UI invoking {Method} for task {TaskId}", method, taskId);
|
||||||
|
await _hub.InvokeCoreAsync(method, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Route the fire-and-forget task actions through the helper**
|
||||||
|
|
||||||
|
In the same file, replace each of these method bodies:
|
||||||
|
|
||||||
|
`RunNowAsync` (line 243):
|
||||||
|
```csharp
|
||||||
|
public Task RunNowAsync(string taskId)
|
||||||
|
=> InvokeForTaskAsync(taskId, "RunNow", taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
`ContinueTaskAsync` (line 248):
|
||||||
|
```csharp
|
||||||
|
public Task ContinueTaskAsync(string taskId, string followUpPrompt)
|
||||||
|
=> InvokeForTaskAsync(taskId, "ContinueTask", taskId, followUpPrompt);
|
||||||
|
```
|
||||||
|
|
||||||
|
`ResetTaskAsync` (line 253):
|
||||||
|
```csharp
|
||||||
|
public Task ResetTaskAsync(string taskId)
|
||||||
|
=> InvokeForTaskAsync(taskId, "ResetTask", taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
`CancelTaskAsync` (line 267):
|
||||||
|
```csharp
|
||||||
|
public Task CancelTaskAsync(string taskId)
|
||||||
|
=> InvokeForTaskAsync(taskId, "CancelTask", taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
`ApproveReviewAsync` (line 389):
|
||||||
|
```csharp
|
||||||
|
public Task ApproveReviewAsync(string taskId)
|
||||||
|
=> InvokeForTaskAsync(taskId, "ApproveReview", taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
`RejectReviewToQueueAsync` (line 394):
|
||||||
|
```csharp
|
||||||
|
public Task RejectReviewToQueueAsync(string taskId, string feedback)
|
||||||
|
=> InvokeForTaskAsync(taskId, "RejectReviewToQueue", taskId, feedback);
|
||||||
|
```
|
||||||
|
|
||||||
|
`RejectReviewToIdleAsync` (line 399):
|
||||||
|
```csharp
|
||||||
|
public Task RejectReviewToIdleAsync(string taskId)
|
||||||
|
=> InvokeForTaskAsync(taskId, "RejectReviewToIdle", taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
`CancelReviewAsync` (line 404):
|
||||||
|
```csharp
|
||||||
|
public Task CancelReviewAsync(string taskId)
|
||||||
|
=> InvokeForTaskAsync(taskId, "CancelReview", taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
> These all previously did `await _hub.InvokeAsync(method, ...)` with no return value, so converting them to expression-bodied delegations preserves behavior. Do **not** touch methods that return DTOs (e.g. `MergeTaskAsync`) or the planning methods — keep this change scoped to the void task actions above.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the DI registration to pass the logger**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.App/Program.cs`, replace line 101:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
sc.AddSingleton(sp => new WorkerClient(
|
||||||
|
sp.GetRequiredService<AppSettings>().SignalRUrl,
|
||||||
|
sp.GetRequiredService<ILogger<WorkerClient>>()));
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `using Microsoft.Extensions.Logging;` to the top of `Program.cs` if not already present.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build the App**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
> Note: `WorkerClient` is faked in tests via the `IWorkerClient` *interface* (hand-rolled fakes implement the interface, they do not subclass `WorkerClient`). This change adds a ctor parameter to the concrete class only and does not alter `IWorkerClient`, so the fakes are unaffected. Confirm by building the test projects in the next step.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build the test projects to confirm fakes still compile**
|
||||||
|
|
||||||
|
Run: `dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release && dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||||
|
Expected: Build succeeded for both.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run the full Worker.Tests suite**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
|
||||||
|
Expected: PASS (all existing tests + the 4 new logging tests).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.App/Program.cs
|
||||||
|
git commit -m "feat(logging): tag UI task actions with TaskId + debug trace lines"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
- [ ] **Build the whole desktop + worker stack in Release:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Run the logging tests:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~Logging"
|
||||||
|
```
|
||||||
|
Expected: PASS (DefaultTaskIdEnricher × 2, BuildConfig × 1, LoggingSetup × 1).
|
||||||
|
|
||||||
|
- [ ] **Manual smoke test (visual-verification gap — needs the user):**
|
||||||
|
1. Run the Worker and App from Rider (Debug build). Confirm both write to one `~/.todo-app/logs/claudedo-*.log` with `app/...` and `worker/...` lines.
|
||||||
|
2. Run a task; grep that file for the task's id — confirm UI (`UI invoking RunNow…`) and Worker lines share the same `[<taskId>]`.
|
||||||
|
3. Build/install the Release app; confirm the log is near-silent (no `Debug`/`Information` noise, `Warning`+ only) and no console window logging.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
|||||||
|
# MyDay Icon Buttons + Terminal Reuse + Sort Icon Fix — Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** Move the "Clear day" and "Prep log" actions into the MyDay header icon row as icon buttons (broom + list), render the prep log in the real `SessionTerminalView` ("cool terminal") by making that control reusable, and fix the invisible Sort icon.
|
||||||
|
|
||||||
|
**Approved design (chat):**
|
||||||
|
- Header icon row (`TasksIslandView.axaml`, the Sort/Eye/Settings `icon-btn` StackPanel) gets two more `icon-btn`, both `IsVisible="{Binding IsMyDayList}"`, inserted after the Eye button: **broom** (`Icon.Broom`) → `ClearDayCommand`, **list** (`Icon.List`) → `ShowPrepLogCommand`. The two full-width text buttons "Prep log" and "Clear day" are removed. "Tag vorbereiten" stays as the full-width button (already opens the prep view via `PrepRequested`).
|
||||||
|
- `SessionTerminalView` becomes reusable via StyledProperties so it renders both the task `Log` and the prep `PrepLog` with the same terminal look. The prep panel in `DetailsIslandView` embeds it instead of the copied `ItemsControl`.
|
||||||
|
- **Sort icon bug:** `PathIcon` fills geometry; `Icon.Sort` is an open-line path (no enclosed area) → invisible. Replace with a filled geometry. New icons (Broom, List) are authored as filled geometries too.
|
||||||
|
|
||||||
|
**Tech:** Avalonia (PathIcon/StreamGeometry, StyledProperty), CommunityToolkit.Mvvm, xUnit.
|
||||||
|
|
||||||
|
## Build/test
|
||||||
|
`.slnx` needs .NET 9 — build the csproj. Use `-c Release` if a Worker locks Debug.
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
GUI cannot be smoke-tested headlessly — note it; the human verifies visuals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task A: Icons + reusable SessionTerminalView
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (icon geometries)
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml` + `SessionTerminalView.axaml.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` (both embeds)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Fix `Icon.Sort` + add `Icon.Broom`, `Icon.List`** as filled geometries in `IslandStyles.axaml` (in the `Styles.Resources` icon block). Replace the existing `Icon.Sort` line and add the two new ones:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- Icon.Sort (filled bars, decreasing width) -->
|
||||||
|
<StreamGeometry x:Key="Icon.Sort">M4 6 H20 V8 H4 Z M4 11 H16 V13 H4 Z M4 16 H11 V18 H4 Z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Broom (filled: handle + binding band + flared bristles) -->
|
||||||
|
<StreamGeometry x:Key="Icon.Broom">M11 3 H13 V10 H11 Z M8.5 10 H15.5 V12 H8.5 Z M9 12 H15 L17 21 H7 Z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.List (filled: square bullets + lines) -->
|
||||||
|
<StreamGeometry x:Key="Icon.List">M4 5 H6 V7 H4 Z M8 5 H20 V7 H8 Z M4 11 H6 V13 H4 Z M8 11 H20 V13 H8 Z M4 17 H6 V19 H4 Z M8 17 H20 V19 H8 Z</StreamGeometry>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add StyledProperties to `SessionTerminalView`** (code-behind `SessionTerminalView.axaml.cs`). Add public StyledProperties and CLR wrappers:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static readonly StyledProperty<System.Collections.IEnumerable?> EntriesProperty =
|
||||||
|
AvaloniaProperty.Register<SessionTerminalView, System.Collections.IEnumerable?>(nameof(Entries));
|
||||||
|
public static readonly StyledProperty<string?> LabelProperty =
|
||||||
|
AvaloniaProperty.Register<SessionTerminalView, string?>(nameof(Label));
|
||||||
|
public static readonly StyledProperty<bool> IsRunningProperty =
|
||||||
|
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsRunning));
|
||||||
|
public static readonly StyledProperty<bool> IsDoneProperty =
|
||||||
|
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsDone));
|
||||||
|
public static readonly StyledProperty<bool> IsFailedProperty =
|
||||||
|
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsFailed));
|
||||||
|
|
||||||
|
public System.Collections.IEnumerable? Entries { get => GetValue(EntriesProperty); set => SetValue(EntriesProperty, value); }
|
||||||
|
public string? Label { get => GetValue(LabelProperty); set => SetValue(LabelProperty, value); }
|
||||||
|
public bool IsRunning { get => GetValue(IsRunningProperty); set => SetValue(IsRunningProperty, value); }
|
||||||
|
public bool IsDone { get => GetValue(IsDoneProperty); set => SetValue(IsDoneProperty, value); }
|
||||||
|
public bool IsFailed { get => GetValue(IsFailedProperty); set => SetValue(IsFailedProperty, value); }
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the existing auto-scroll hook (which cast `DataContext as DetailsIslandViewModel` and watched `.Log.CollectionChanged`) with one that watches whichever collection `Entries` points at: in `OnPropertyChanged`, when `change.Property == EntriesProperty`, detach the old `INotifyCollectionChanged.CollectionChanged` handler and attach to the new value (if it implements `INotifyCollectionChanged`); the handler scrolls the existing ScrollViewer to the end (reuse the existing scroll logic / named ScrollViewer). Keep the named ScrollViewer's `x:Name`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Repoint `SessionTerminalView.axaml` internal bindings to the control's own properties.** Give the root `UserControl` `x:Name="Root"`. Change:
|
||||||
|
- the `ItemsControl ItemsSource="{Binding Log}"` → `ItemsSource="{Binding #Root.Entries}"`
|
||||||
|
- the label `TextBlock` `Text="{Binding BranchLine, StringFormat='claude-session · {0}'}"` (or whatever it is) → `Text="{Binding #Root.Label}"`
|
||||||
|
- the LIVE chip `IsVisible="{Binding IsRunning}"` → `{Binding #Root.IsRunning}`; DONE → `#Root.IsDone`; FAILED → `#Root.IsFailed`.
|
||||||
|
Keep the `LogLineViewModel` item template as-is (it binds the item, not the VM). The `x:DataType` can stay `DetailsIslandViewModel` (element-name bindings to `#Root` don't depend on it) or be removed if it causes compile issues — verify the build.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update both embeds in `DetailsIslandView.axaml`.**
|
||||||
|
- Task embed (currently `<islands:SessionTerminalView MaxHeight="420"/>`):
|
||||||
|
```xml
|
||||||
|
<islands:SessionTerminalView MaxHeight="420"
|
||||||
|
Entries="{Binding Log}"
|
||||||
|
Label="{Binding BranchLine, StringFormat='claude-session · {0}'}"
|
||||||
|
IsRunning="{Binding IsRunning}" IsDone="{Binding IsDone}" IsFailed="{Binding IsFailed}"/>
|
||||||
|
```
|
||||||
|
(Use the exact label binding the old internal header used — match the prior `StringFormat` text precisely so the task view is visually unchanged.)
|
||||||
|
- Prep panel: replace the whole copied `ItemsControl` (and its surrounding `ScrollViewer`/title) with:
|
||||||
|
```xml
|
||||||
|
<islands:SessionTerminalView
|
||||||
|
Entries="{Binding PrepLog}" Label="daily-prep"
|
||||||
|
IsRunning="{Binding IsPrepRunning}"/>
|
||||||
|
```
|
||||||
|
Keep the panel wrapper `<Panel IsVisible="{Binding IsPrepMode}">`. Drop the now-redundant `details.prepTitle` title TextBlock (the terminal header shows the `daily-prep` label). Leave the `details.prepTitle` locale key in place (harmless) OR remove it from both en/de if you prefer — if removing, run the localization test.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build the App; confirm no binding/compile errors.**
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
(The existing DetailsIsland prep tests must still pass — `PrepLog`/`IsPrepMode`/`ShowPrep` are unchanged.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit** (stage only Task A files; do NOT `git add -A`):
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(daily-prep): reuse SessionTerminal for prep log; fix invisible Sort icon; add Broom/List icons"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task B: MyDay header icon buttons
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json`
|
||||||
|
|
||||||
|
Depends on Task A (uses `Icon.Broom` / `Icon.List`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add two `icon-btn` to the header icon StackPanel** (the one with Sort/Eye/Settings), inserted right after the Eye button and before Settings, both MyDay-only:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Button Classes="icon-btn" IsVisible="{Binding IsMyDayList}"
|
||||||
|
Command="{Binding ClearDayCommand}" ToolTip.Tip="{loc:Tr tasks.clearDayTip}">
|
||||||
|
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Broom}"/>
|
||||||
|
</Button>
|
||||||
|
<Button Classes="icon-btn" IsVisible="{Binding IsMyDayList}"
|
||||||
|
Command="{Binding ShowPrepLogCommand}" ToolTip.Tip="{loc:Tr tasks.prepLogTip}">
|
||||||
|
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.List}"/>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the two full-width buttons** "Prep log" (`ShowPrepLogCommand`) and "Clear day" (`ClearDayCommand`) from the DockPanel button stack. Keep the "Prepare day" (`PrepareDayCommand`) full-width button and the Notes pinned-row button.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Locales.** Add `tasks.clearDayTip` (en "Clear day", de "Tag leeren") and `tasks.prepLogTip` (en "Prep log", de "Vorbereitungs-Log") to both json files. Remove the now-unused `tasks.clearDay` and `tasks.prepLog` keys from both (keep en/de in parity).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build + test.**
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual smoke (human):** on MyDay the header shows Sort (now visible) + Eye + Broom + List + Settings; broom clears the day; list opens the prep terminal; "Tag vorbereiten" opens the prep terminal and streams; the three MyDay-only controls hide on other lists; the task session terminal still renders normally.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit** (stage only Task B files):
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(daily-prep): move Clear-day and Prep-log into MyDay header icon row"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes / risks
|
||||||
|
- Element-name bindings (`#Root.*`) require the `UserControl` to have `x:Name="Root"`; verify compiled bindings accept them (they do in Avalonia).
|
||||||
|
- The auto-scroll hook must re-subscribe when `Entries` changes; without it the prep log won't auto-scroll.
|
||||||
|
- `ClearDayCommand` / `ShowPrepLogCommand` already exist on `TasksIslandViewModel` — no VM changes; existing VM tests remain valid.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Move "Plan day" into the Prep-Log Window — Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** Guard daily-prep planning behind a second click. The MyDay header's full-width "Tag vorbereiten" button is removed; instead the user opens the prep-log window (list icon), sees the last run or an empty-state hint, and clicks a **"Plan day"** button inside that window to run the prep.
|
||||||
|
|
||||||
|
**Approved flow:** Header list-icon (`ShowPrepLogCommand`) opens the prep window → if empty, an empty-state hint shows → "Plan day" button in the window runs `RunDailyPrepNowAsync()`.
|
||||||
|
|
||||||
|
**Tech:** Avalonia + CommunityToolkit.Mvvm, xUnit.
|
||||||
|
|
||||||
|
## Build/test
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
GUI not headlessly verifiable — note it; human verifies visuals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task: relocate planning trigger + empty-state
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (remove PrepareDay)
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` (remove header button)
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (PlanDayCommand + empty-state)
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` (prep panel toolbar + empty hint)
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPrepModeTests.cs`, and the existing `TasksIslandDailyPrepTests.cs` (remove the obsolete prepare test)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write/adjust tests first.**
|
||||||
|
- In `DetailsIslandPrepModeTests.cs` add:
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task PlanDayCommand_calls_worker()
|
||||||
|
{
|
||||||
|
var stub = new StubWorkerClient();
|
||||||
|
var vm = NewDetailsVm(stub);
|
||||||
|
await vm.PlanDayCommand.ExecuteAsync(null);
|
||||||
|
Assert.Equal(1, stub.RunDailyPrepNowCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShowPrepEmptyState_true_when_empty_and_not_running()
|
||||||
|
{
|
||||||
|
var vm = NewDetailsVm(new StubWorkerClient());
|
||||||
|
Assert.True(vm.ShowPrepEmptyState);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`StubWorkerClient` needs a `RunDailyPrepNowCalls` counter incremented in `RunDailyPrepNowAsync` (add if missing; it currently likely returns `Task.FromResult(true)` — keep that and bump a counter).
|
||||||
|
- In `TasksIslandDailyPrepTests.cs` **remove** `PrepareDayCommand_raises_PrepRequested` (the command is being deleted). Keep `ClearDayCommand_calls_worker`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL/compile error.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: `TasksIslandViewModel` — remove planning trigger.**
|
||||||
|
- Delete the `PrepareDayAsync` `[RelayCommand]` entirely.
|
||||||
|
- Keep the `PrepRequested` event and `ShowPrepLog` command (the list icon still raises `PrepRequested` to open the window).
|
||||||
|
- Grep the VM for any remaining `PrepareDay` references and remove them.
|
||||||
|
|
||||||
|
- [ ] **Step 4: `TasksIslandView.axaml` — remove the header button.** Delete the full-width "Prepare day" `<Button … Command="{Binding PrepareDayCommand}" …>`. Leave the Notes pinned-row button, and the header icon buttons (broom = ClearDay, list = ShowPrepLog) untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 5: `DetailsIslandViewModel` — add PlanDayCommand + empty-state.**
|
||||||
|
- Add:
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task PlanDayAsync()
|
||||||
|
{
|
||||||
|
if (_worker is null) return;
|
||||||
|
try { await _worker.RunDailyPrepNowAsync(); }
|
||||||
|
catch { /* worker offline; PrepStarted/PrepLine will reconcile */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0;
|
||||||
|
```
|
||||||
|
- Notify `ShowPrepEmptyState`: in the constructor add `PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));`, and add `partial void OnIsPrepRunningChanged(bool value) => OnPropertyChanged(nameof(ShowPrepEmptyState));`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: `DetailsIslandView.axaml` — prep panel toolbar + empty hint.** In the `<Panel IsVisible="{Binding IsPrepMode}">`, wrap the existing `SessionTerminalView` in a `DockPanel`; dock a top toolbar row with the Plan-day button, and overlay/stack an empty-state hint:
|
||||||
|
```xml
|
||||||
|
<Panel IsVisible="{Binding IsPrepMode}">
|
||||||
|
<DockPanel>
|
||||||
|
<Border DockPanel.Dock="Top" Padding="12,8">
|
||||||
|
<Button Classes="btn primary"
|
||||||
|
Command="{Binding PlanDayCommand}"
|
||||||
|
IsEnabled="{Binding !IsPrepRunning}"
|
||||||
|
Content="{loc:Tr details.planDay}"/>
|
||||||
|
</Border>
|
||||||
|
<Panel>
|
||||||
|
<islands:SessionTerminalView
|
||||||
|
Entries="{Binding PrepLog}" Label="daily-prep"
|
||||||
|
IsRunning="{Binding IsPrepRunning}"/>
|
||||||
|
<TextBlock IsVisible="{Binding ShowPrepEmptyState}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
Text="{loc:Tr details.prepEmpty}"/>
|
||||||
|
</Panel>
|
||||||
|
</DockPanel>
|
||||||
|
</Panel>
|
||||||
|
```
|
||||||
|
(Match the surrounding view's class names/brushes; use the existing button class style seen elsewhere, e.g. `Classes="btn"` — verify `primary` exists, else plain `btn`.)
|
||||||
|
|
||||||
|
- [ ] **Step 7: Locales.** Add `details.planDay` (en "Plan day", de "Tag planen") and `details.prepEmpty` (en "No prep run today yet — click Plan day", de "Heute noch keine Vorbereitung — klick Tag planen") to both json files. Remove the now-unused `tasks.prepareDay` key from both (grep first to confirm no other reference). Keep en/de key parity.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Build + tests.**
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Manual smoke (human):** on MyDay there is no "Tag vorbereiten" button; the list icon opens the prep window showing the empty hint; "Plan day" runs the prep and streams; the hint disappears while running; after restart the persisted last run shows and "Plan day" is available to re-run.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit:**
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(daily-prep): trigger planning from inside the prep-log window with an empty-state hint"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes / risks
|
||||||
|
- `PrepRequested` and `ShowPrepLogCommand` stay — only `PrepareDayCommand` and its header button are removed.
|
||||||
|
- `ShowPrepEmptyState` must re-notify on both `PrepLog` changes and `IsPrepRunning` changes, else the hint won't hide when a run starts or lines arrive.
|
||||||
|
- Removing `tasks.prepareDay`: confirm via grep it has no remaining references before deleting (keep locale parity or the Localization.Tests parity check fails).
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# Persist Daily-Prep Log Across Restarts — Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** The prep log currently lives only in memory (`DetailsIslandViewModel.PrepLog`), so after an app restart the prep terminal is empty. Persist the last prep run's output to a file in the worker and load it into the prep terminal when opened.
|
||||||
|
|
||||||
|
**Root cause (confirmed):** `PrimeRunner.FireAsync` streams stdout lines via `_broadcaster.PrepLineAsync(line)` only — it writes no file and stores no record. `PrepLog` is an in-memory `ObservableCollection` populated only by live `PrepLine` events. Nothing persists → empty after restart.
|
||||||
|
|
||||||
|
**Approach:** Worker writes each streamed line to `<appdata>/logs/daily-prep.log` (truncated at run start = last run only) using the existing `LogWriter`. A new hub method `GetLastPrepLog()` returns the file (tail-capped, like `get_task_log`). The UI loads it into `PrepLog` when the prep view opens, but only when `PrepLog` is empty and no run is in progress.
|
||||||
|
|
||||||
|
**Tech:** ASP.NET Core SignalR, Avalonia + CommunityToolkit.Mvvm, xUnit.
|
||||||
|
|
||||||
|
## Build/test
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
GUI not headlessly verifiable — note it; human verifies visuals.
|
||||||
|
|
||||||
|
## Shared constant
|
||||||
|
The prep-log path must be identical in `PrimeRunner` (writer) and `WorkerHub` (reader). Define it once and reference from both:
|
||||||
|
`Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "logs", "daily-prep.log")`.
|
||||||
|
Add a small static helper so both sides agree, e.g. in `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` (already the prep "home"):
|
||||||
|
```csharp
|
||||||
|
public static string LogPath() =>
|
||||||
|
System.IO.Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "logs", "daily-prep.log");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Worker — write the prep log + serve it
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` (add `LogPath()` helper)
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `DailyPrepPrompt.LogPath()`** (code above).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test.** Extend the existing streaming test (or add one) asserting that after `FireAsync` with emitted stdout lines, the file at `DailyPrepPrompt.LogPath()` contains those lines, and that a prior run's content is replaced (truncate-on-start). Since the path is the real app-data logs dir, the test should delete the file first and clean up after; assert exact line content.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task FireAsync_writes_last_run_to_prep_log_file()
|
||||||
|
{
|
||||||
|
var path = DailyPrepPrompt.LogPath();
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
|
||||||
|
var claude = new FakeClaudeProcess(emitLines: new[] { "lineA", "lineB" }, exitCode: 0, result: "ok");
|
||||||
|
var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
|
||||||
|
await runner.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None);
|
||||||
|
|
||||||
|
var contents = await File.ReadAllTextAsync(path);
|
||||||
|
Assert.Contains("lineA", contents);
|
||||||
|
Assert.Contains("lineB", contents);
|
||||||
|
|
||||||
|
// Truncation: a second run with different lines replaces the file.
|
||||||
|
var claude2 = new FakeClaudeProcess(emitLines: new[] { "lineC" }, exitCode: 0, result: "ok");
|
||||||
|
var runner2 = NewRunner(claude2, new RecordingPrimeBroadcaster());
|
||||||
|
await runner2.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None);
|
||||||
|
var after = await File.ReadAllTextAsync(path);
|
||||||
|
Assert.DoesNotContain("lineA", after);
|
||||||
|
Assert.Contains("lineC", after);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run — expect FAIL.**
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the file in `PrimeRunner.FireAsync`.** After the gate is acquired and before `RunAsync`: compute `var logPath = DailyPrepPrompt.LogPath();`, delete it if present (truncate → last run only), then create `await using var logWriter = new LogWriter(logPath);`. Change the stream callback to write AND broadcast:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var logPath = DailyPrepPrompt.LogPath();
|
||||||
|
try { if (File.Exists(logPath)) File.Delete(logPath); } catch { /* best effort */ }
|
||||||
|
await using var logWriter = new LogWriter(logPath);
|
||||||
|
|
||||||
|
await _broadcaster.PrepStartedAsync();
|
||||||
|
// ... build prompt/args/timeoutCts ...
|
||||||
|
var result = await _claude.RunAsync(
|
||||||
|
arguments: args, prompt: prompt, workingDirectory: cwd,
|
||||||
|
onStdoutLine: async line =>
|
||||||
|
{
|
||||||
|
await logWriter.WriteLineAsync(line);
|
||||||
|
await _broadcaster.PrepLineAsync(line);
|
||||||
|
},
|
||||||
|
ct: timeoutCts.Token);
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the existing `success`/`finally`/`PrepFinishedAsync`/gate logic. `using ClaudeDo.Worker.Runner;` is already present (LogWriter lives there). The `await using` LogWriter disposes (flushes) before the method returns.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run — expect PASS.** Build the Worker.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add `WorkerHub.GetLastPrepLog()`** (no ctor change — reads the static path):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<string> GetLastPrepLog()
|
||||||
|
{
|
||||||
|
var path = DailyPrepPrompt.LogPath();
|
||||||
|
if (!File.Exists(path)) return Task.FromResult(string.Empty);
|
||||||
|
|
||||||
|
const int maxBytes = 256 * 1024;
|
||||||
|
var bytes = File.ReadAllBytes(path);
|
||||||
|
var text = bytes.Length <= maxBytes
|
||||||
|
? System.Text.Encoding.UTF8.GetString(bytes)
|
||||||
|
: System.Text.Encoding.UTF8.GetString(bytes, bytes.Length - maxBytes, maxBytes);
|
||||||
|
return Task.FromResult(text);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `using ClaudeDo.Worker.Prime;` to `WorkerHub.cs` if not present.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build Worker; run the full Worker.Tests project.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit** (stage only Task 1 files):
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(daily-prep): persist last prep run to a log file and serve it via GetLastPrepLog"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: UI — load the persisted prep log when opening
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
||||||
|
- Modify fakes: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (FakeWorkerClient)
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPrepModeTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Declare on `IWorkerClient`:** `Task<string> GetLastPrepLogAsync();`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement in `WorkerClient`:** `public Task<string> GetLastPrepLogAsync() => _hub.InvokeAsync<string>("GetLastPrepLog");` (match neighbouring call style; if there is a `TryInvokeAsync` helper for resilience, mirror `GetWeekReportAsync` and return `?? string.Empty`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update fakes.** Add `public Task<string> GetLastPrepLogAsync() => Task.FromResult(string.Empty);` to both fakes. In `StubWorkerClient`, make it return a settable backing field, e.g. `public string LastPrepLog = ""; public Task<string> GetLastPrepLogAsync() => Task.FromResult(LastPrepLog);`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the failing test.**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task ShowPrep_loads_persisted_log_when_empty()
|
||||||
|
{
|
||||||
|
var stub = new StubWorkerClient { LastPrepLog = "{\"type\":\"assistant\",\"text\":\"restored\"}" };
|
||||||
|
var vm = NewDetailsVm(stub);
|
||||||
|
|
||||||
|
vm.ShowPrep();
|
||||||
|
await Task.Delay(50); // allow the async load to run; or expose the load task to await deterministically
|
||||||
|
|
||||||
|
Assert.NotEmpty(vm.PrepLog);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer determinism over `Task.Delay`: have `ShowPrep` start the load and expose the in-flight `Task` (e.g. a `LoadLastPrepLogAsync()` method the test can call/await directly), then assert. Use whichever the existing test style favors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Implement load in `DetailsIslandViewModel`.** Add a method and call it from `ShowPrep`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public void ShowPrep()
|
||||||
|
{
|
||||||
|
Bind(null);
|
||||||
|
IsNotesMode = false;
|
||||||
|
IsPrepMode = true;
|
||||||
|
_ = LoadLastPrepLogIfEmptyAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadLastPrepLogIfEmptyAsync()
|
||||||
|
{
|
||||||
|
if (_worker is null || IsPrepRunning || PrepLog.Count > 0) return;
|
||||||
|
string text;
|
||||||
|
try { text = await _worker.GetLastPrepLogAsync(); }
|
||||||
|
catch { return; }
|
||||||
|
if (IsPrepRunning || PrepLog.Count > 0) return; // a live run may have started meanwhile
|
||||||
|
foreach (var line in text.Split('\n'))
|
||||||
|
{
|
||||||
|
var trimmed = line.TrimEnd('\r');
|
||||||
|
if (trimmed.Length > 0) AppendStdoutLine(PrepLog, trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This reuses the existing `AppendStdoutLine(PrepLog, line)` formatter path, so persisted NDJSON renders identically to the live stream. The guards ensure it never overwrites a live run (`PrepStarted` clears `PrepLog` and sets `IsPrepRunning`) or an already-loaded log.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build App + run UI tests.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Manual smoke (human):** run a prep, restart the app, open the prep log on MyDay → the last run's output is shown.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit** (stage only Task 2 files):
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(daily-prep): load persisted prep log into the terminal on open"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes / risks
|
||||||
|
- `PrimeRunner` writes via the same `LogWriter` pattern `TaskRunner` uses; concurrency behavior matches existing code (no new locking introduced).
|
||||||
|
- Path is shared via `DailyPrepPrompt.LogPath()` so writer and reader never diverge.
|
||||||
|
- Load is guarded (`PrepLog empty && !IsPrepRunning`) to avoid clobbering a live stream — the order of `ShowPrep`'s flag set vs. the async load matters; re-check the guard after the await.
|
||||||
|
- Last run only (file truncated each run); history is out of scope.
|
||||||
@@ -0,0 +1,801 @@
|
|||||||
|
# Refine Task 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. Subagents use the `sonnet` model and stage files explicitly by path (never `git add -A`).
|
||||||
|
|
||||||
|
**Goal:** Add a one-click "Refine Task" button to each Idle task card that spawns a headless Claude session which rewrites the task's description and adds subtasks (steps), then updates the task live in the UI.
|
||||||
|
|
||||||
|
**Architecture:** A new headless `RefineRunner` (modeled on `PrimeRunner`) runs `claude -p` read-only in the list's working dir, using the globally-registered `claudedo` MCP. Claude calls `update_task` (existing) and a new `add_subtask` tool. The task stays `Idle`; refine only mutates Title/Description/subtasks. UI shows a busy state via new `RefineStarted`/`RefineFinished` SignalR events; content updates arrive via the existing `TaskUpdated` events.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, ASP.NET Core + SignalR, EF Core (SQLite), Avalonia 12 (CommunityToolkit.Mvvm), ModelContextProtocol server tools, xUnit.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-04-refine-task-design.md`
|
||||||
|
|
||||||
|
**Build/test reminders:** Build individual csproj with `-c Release` (a running Worker locks Debug). `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`, `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`, `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`. Keep `locales/en.json` and `locales/de.json` keys in parity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `src/ClaudeDo.Worker/Refine/RefineRunner.cs` — headless refine run orchestrator
|
||||||
|
- `src/ClaudeDo.Worker/Refine/RefinePrompt.cs` — prompt + CLI args + log path helper
|
||||||
|
- `src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs` — interface + `RefineRunOutcome`
|
||||||
|
- `src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs` — `RefineStartedAsync`/`RefineFinishedAsync`
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `src/ClaudeDo.Data/PromptFiles.cs` — add `Refine` to `PromptKind`, path, default
|
||||||
|
- `src/ClaudeDo.Worker/External/ExternalMcpService.cs` — add `add_subtask` tool
|
||||||
|
- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` — implement `RefineStarted`/`RefineFinished` + `IRefineBroadcaster`
|
||||||
|
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add `RefineTask(string taskId)` method
|
||||||
|
- `src/ClaudeDo.Worker/Program.cs` — register `IRefineRunner`/`IRefineBroadcaster`
|
||||||
|
- `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` — `RefineTaskAsync` + `RefineStartedEvent`/`RefineFinishedEvent`
|
||||||
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — implement call + subscribe events
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — `IsRefining` + `CanRefine`
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — `RefineTaskCommand` + event wiring
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` — `Icon.Refine` geometry
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — refine button
|
||||||
|
- `locales/en.json`, `locales/de.json` — tooltip key
|
||||||
|
- Test fakes implementing `IWorkerClient` in `tests/ClaudeDo.Ui.Tests` (and any other project that hand-rolls it)
|
||||||
|
|
||||||
|
**Test:**
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs`
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `add_subtask` MCP tool
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`
|
||||||
|
|
||||||
|
The `ExternalMcpService` already injects `IDbContextFactory<ClaudeDoDbContext> _dbFactory`, `TaskRepository _tasks`, and `HubBroadcaster _broadcaster`. Reuse them; new up a `SubtaskRepository` from a fresh context (matching the `SetMyDay`/`GetDailyPrepCandidates` pattern in the same file).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`. Follow the existing External tool test setup in that test project (look at a sibling test, e.g. an `ExternalMcpService`/`UpdateTask` test, for the in-memory-real-SQLite fixture + broadcaster fake construction; reuse that exact fixture pattern).
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
public class AddSubtaskToolTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AddSubtask_appends_row_with_next_order()
|
||||||
|
{
|
||||||
|
await using var f = new ExternalMcpServiceFixture(); // reuse the project's existing fixture helper
|
||||||
|
var list = await f.SeedListAsync();
|
||||||
|
var task = await f.SeedTaskAsync(list.Id, status: TaskStatus.Idle);
|
||||||
|
|
||||||
|
await f.Service.AddSubtask(task.Id, "First step", orderNum: null, CancellationToken.None);
|
||||||
|
await f.Service.AddSubtask(task.Id, "Second step", orderNum: null, CancellationToken.None);
|
||||||
|
|
||||||
|
await using var ctx = f.CreateContext();
|
||||||
|
var subs = await new SubtaskRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||||
|
Assert.Equal(new[] { "First step", "Second step" }, subs.Select(s => s.Title));
|
||||||
|
Assert.Equal(new[] { 0, 1 }, subs.Select(s => s.OrderNum));
|
||||||
|
Assert.All(subs, s => Assert.False(s.Completed));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddSubtask_refuses_running_task()
|
||||||
|
{
|
||||||
|
await using var f = new ExternalMcpServiceFixture();
|
||||||
|
var list = await f.SeedListAsync();
|
||||||
|
var task = await f.SeedTaskAsync(list.Id, status: TaskStatus.Running);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => f.Service.AddSubtask(task.Id, "x", null, CancellationToken.None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> If the test project has no reusable `ExternalMcpServiceFixture`, mirror the construction already used by the nearest existing `ExternalMcpService` test (same ctor args, real SQLite via `IDbContextFactory`, a no-op/recording broadcaster). Do not invent a new pattern.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails** (compile error / method missing)
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter AddSubtaskToolTests`
|
||||||
|
Expected: FAIL — `AddSubtask` not defined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `add_subtask`**
|
||||||
|
|
||||||
|
Add to `ExternalMcpService` (near `UpdateTask`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Append a subtask (step) to a task. orderNum defaults to the end. " +
|
||||||
|
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")]
|
||||||
|
public async Task<TaskDto> AddSubtask(
|
||||||
|
string taskId,
|
||||||
|
string title,
|
||||||
|
int? orderNum,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
throw new InvalidOperationException("title is required.");
|
||||||
|
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var tasks = new TaskRepository(ctx);
|
||||||
|
var subtasks = new SubtaskRepository(ctx);
|
||||||
|
|
||||||
|
var task = await tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
if (task.Status == TaskStatus.Running)
|
||||||
|
throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");
|
||||||
|
|
||||||
|
var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
|
||||||
|
var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);
|
||||||
|
|
||||||
|
await subtasks.AddAsync(new SubtaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
TaskId = taskId,
|
||||||
|
Title = title.Trim(),
|
||||||
|
Completed = false,
|
||||||
|
OrderNum = order,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
|
return ToDto(task);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `using ClaudeDo.Data.Repositories;` if not present (it is). `SubtaskEntity` is in `ClaudeDo.Data.Models` (already imported).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter AddSubtaskToolTests`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs
|
||||||
|
git commit -m "feat(mcp): add add_subtask tool to claudedo MCP"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Refine prompt (`PromptKind.Refine`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/PromptFiles.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the enum value**
|
||||||
|
|
||||||
|
Change the enum line in `PromptFiles.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the path mapping**
|
||||||
|
|
||||||
|
In `PathFor`, add before the `_ => throw`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
PromptKind.Refine => Path.Combine(Root, "refine.md"),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the default mapping**
|
||||||
|
|
||||||
|
In `DefaultFor`, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
PromptKind.Refine => RefineDefault,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the default prompt constant**
|
||||||
|
|
||||||
|
Add near the other `private const string ...Default` blocks:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private const string RefineDefault = """
|
||||||
|
You are refining ONE ClaudeDo task so it is ready to run autonomously later.
|
||||||
|
You are NOT executing the task — only improving its specification.
|
||||||
|
|
||||||
|
The task you are refining:
|
||||||
|
- id: {taskId}
|
||||||
|
- title: {title}
|
||||||
|
- description: {description}
|
||||||
|
- current subtasks (steps):
|
||||||
|
{subtasks}
|
||||||
|
|
||||||
|
What to do:
|
||||||
|
1. If a repository is available, read the relevant code (read-only) to ground your
|
||||||
|
understanding. Do NOT edit, create, or delete any files. Do NOT run commands.
|
||||||
|
2. Rewrite the description so it is clear, specific, and self-contained: what to change,
|
||||||
|
where, and what "done" looks like. Keep scope tight — do not invent adjacent work.
|
||||||
|
3. Call mcp__claudedo__update_task to save the improved title (only if it genuinely
|
||||||
|
helps) and description.
|
||||||
|
4. If the work is clearer as discrete steps, add them as subtasks with
|
||||||
|
mcp__claudedo__add_subtask (one call per step, in order). Only add steps that are
|
||||||
|
not already present in the current subtasks above.
|
||||||
|
|
||||||
|
Use ONLY these tools: mcp__claudedo__get_task, mcp__claudedo__update_task,
|
||||||
|
mcp__claudedo__add_subtask, and read-only Read/Grep/Glob. When you have updated the
|
||||||
|
task, stop.
|
||||||
|
""";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build to verify it compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/PromptFiles.cs
|
||||||
|
git commit -m "feat(prompts): add Refine prompt kind and default"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: RefineRunner, interfaces, prompt/args helper
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs`
|
||||||
|
- Create: `src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs`
|
||||||
|
- Create: `src/ClaudeDo.Worker/Refine/RefinePrompt.cs`
|
||||||
|
- Create: `src/ClaudeDo.Worker/Refine/RefineRunner.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `IRefineRunner.cs`**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Worker.Refine;
|
||||||
|
|
||||||
|
public interface IRefineRunner
|
||||||
|
{
|
||||||
|
Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RefineRunOutcome(bool Success, string Message);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `IRefineBroadcaster.cs`**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Worker.Refine;
|
||||||
|
|
||||||
|
public interface IRefineBroadcaster
|
||||||
|
{
|
||||||
|
Task RefineStartedAsync(string taskId);
|
||||||
|
Task RefineFinishedAsync(string taskId, bool success, string? error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `RefinePrompt.cs`**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Refine;
|
||||||
|
|
||||||
|
public static class RefinePrompt
|
||||||
|
{
|
||||||
|
public const string GetTaskTool = "mcp__claudedo__get_task";
|
||||||
|
public const string UpdateTaskTool = "mcp__claudedo__update_task";
|
||||||
|
public const string AddSubtaskTool = "mcp__claudedo__add_subtask";
|
||||||
|
|
||||||
|
public static string LogPath(string taskId) =>
|
||||||
|
System.IO.Path.Combine(Paths.AppDataRoot(), "logs", $"refine-{Short(taskId)}.log");
|
||||||
|
|
||||||
|
// canReadRepo=false drops the read-only filesystem tools (text-only fallback).
|
||||||
|
public static string BuildArgs(int maxTurns, bool canReadRepo)
|
||||||
|
{
|
||||||
|
var tools = canReadRepo
|
||||||
|
? $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool} Read Grep Glob"
|
||||||
|
: $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool}";
|
||||||
|
return "-p --output-format stream-json --verbose --permission-mode acceptEdits " +
|
||||||
|
$"--max-turns {maxTurns} --allowedTools {tools}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BuildPrompt(TaskEntity task, IEnumerable<SubtaskEntity> subtasks)
|
||||||
|
{
|
||||||
|
var open = subtasks.Where(s => !s.Completed).Select(s => $"- {s.Title}").ToList();
|
||||||
|
var subText = open.Count == 0 ? "(none)" : string.Join("\n", open);
|
||||||
|
return PromptFiles.Render(PromptKind.Refine, new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["taskId"] = task.Id,
|
||||||
|
["title"] = task.Title,
|
||||||
|
["description"] = string.IsNullOrWhiteSpace(task.Description) ? "(empty)" : task.Description!,
|
||||||
|
["subtasks"] = subText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Short(string id) => id.Length >= 8 ? id[..8] : id;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write `RefinePromptTests.cs`**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Worker.Refine;
|
||||||
|
|
||||||
|
public class RefinePromptTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildArgs_includes_read_tools_when_repo_available()
|
||||||
|
{
|
||||||
|
var args = RefinePrompt.BuildArgs(20, canReadRepo: true);
|
||||||
|
Assert.Contains("--permission-mode acceptEdits", args);
|
||||||
|
Assert.Contains("mcp__claudedo__add_subtask", args);
|
||||||
|
Assert.Contains(" Read Grep Glob", args);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildArgs_drops_read_tools_in_text_only_mode()
|
||||||
|
{
|
||||||
|
var args = RefinePrompt.BuildArgs(20, canReadRepo: false);
|
||||||
|
Assert.DoesNotContain("Glob", args);
|
||||||
|
Assert.Contains("mcp__claudedo__update_task", args);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPrompt_seeds_task_fields_and_open_subtasks()
|
||||||
|
{
|
||||||
|
var task = new TaskEntity { Id = "abc12345", ListId = "l", Title = "T", Description = "D",
|
||||||
|
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow };
|
||||||
|
var subs = new[]
|
||||||
|
{
|
||||||
|
new SubtaskEntity { Id="1", TaskId="abc12345", Title="open one", Completed=false, OrderNum=0, CreatedAt=DateTime.UtcNow },
|
||||||
|
new SubtaskEntity { Id="2", TaskId="abc12345", Title="done one", Completed=true, OrderNum=1, CreatedAt=DateTime.UtcNow },
|
||||||
|
};
|
||||||
|
var prompt = RefinePrompt.BuildPrompt(task, subs);
|
||||||
|
Assert.Contains("abc12345", prompt);
|
||||||
|
Assert.Contains("open one", prompt);
|
||||||
|
Assert.DoesNotContain("done one", prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RefinePromptTests`
|
||||||
|
Expected: PASS (3 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create `RefineRunner.cs`**
|
||||||
|
|
||||||
|
`IClaudeProcess.RunAsync(arguments, prompt, workingDirectory, onStdoutLine, ct)` returns a result with `.IsSuccess` and `.ExitCode` (same as used by `PrimeRunner`). Resolve the working dir from the task's list; fall back to a sandbox dir + text-only when missing/invalid. Per-task single-flight via a guarded `HashSet<string>`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Refine;
|
||||||
|
|
||||||
|
public sealed class RefineRunner : IRefineRunner
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
|
||||||
|
private const int MaxTurns = 25;
|
||||||
|
|
||||||
|
private readonly IClaudeProcess _claude;
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly ILogger<RefineRunner> _logger;
|
||||||
|
private readonly IRefineBroadcaster _broadcaster;
|
||||||
|
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private readonly HashSet<string> _inFlight = new();
|
||||||
|
|
||||||
|
public RefineRunner(
|
||||||
|
IClaudeProcess claude,
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
ILogger<RefineRunner> logger,
|
||||||
|
IRefineBroadcaster broadcaster)
|
||||||
|
{
|
||||||
|
_claude = claude;
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_logger = logger;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_inFlight.Add(taskId))
|
||||||
|
return new RefineRunOutcome(false, "Already refining this task");
|
||||||
|
}
|
||||||
|
|
||||||
|
var success = false;
|
||||||
|
string? error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ClaudeDo.Data.Models.TaskEntity task;
|
||||||
|
List<ClaudeDo.Data.Models.SubtaskEntity> subs;
|
||||||
|
string? workingDir;
|
||||||
|
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
|
||||||
|
{
|
||||||
|
var tasks = new TaskRepository(dbCtx);
|
||||||
|
task = await tasks.GetByIdAsync(taskId, ct)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
if (task.Status != TaskStatus.Idle)
|
||||||
|
return new RefineRunOutcome(false, $"Task must be Idle to refine (is {task.Status}).");
|
||||||
|
subs = await new SubtaskRepository(dbCtx).GetByTaskIdAsync(taskId, ct);
|
||||||
|
var list = await new ListRepository(dbCtx).GetByIdAsync(task.ListId, ct);
|
||||||
|
workingDir = list?.WorkingDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
var canReadRepo = !string.IsNullOrWhiteSpace(workingDir) && Directory.Exists(workingDir);
|
||||||
|
var cwd = canReadRepo ? workingDir! : Paths.AppDataRoot();
|
||||||
|
Directory.CreateDirectory(cwd);
|
||||||
|
|
||||||
|
var logPath = RefinePrompt.LogPath(taskId);
|
||||||
|
try { if (File.Exists(logPath)) File.Delete(logPath); } catch { }
|
||||||
|
await using var logWriter = new LogWriter(logPath);
|
||||||
|
|
||||||
|
await _broadcaster.RefineStartedAsync(taskId);
|
||||||
|
|
||||||
|
var prompt = RefinePrompt.BuildPrompt(task, subs);
|
||||||
|
var args = RefinePrompt.BuildArgs(MaxTurns, canReadRepo);
|
||||||
|
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeoutCts.CancelAfter(RunTimeout);
|
||||||
|
|
||||||
|
var result = await _claude.RunAsync(
|
||||||
|
arguments: args,
|
||||||
|
prompt: prompt,
|
||||||
|
workingDirectory: cwd,
|
||||||
|
onStdoutLine: async line => await logWriter.WriteLineAsync(line),
|
||||||
|
ct: timeoutCts.Token);
|
||||||
|
|
||||||
|
success = result.IsSuccess;
|
||||||
|
if (!success) error = $"exit code {result.ExitCode}";
|
||||||
|
return success
|
||||||
|
? new RefineRunOutcome(true, "Refine complete")
|
||||||
|
: new RefineRunOutcome(false, error!);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
error = $"timed out after {RunTimeout.TotalMinutes:0} min";
|
||||||
|
return new RefineRunOutcome(false, error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Refine run failed for {TaskId}", taskId);
|
||||||
|
error = ex.Message;
|
||||||
|
return new RefineRunOutcome(false, ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _broadcaster.RefineFinishedAsync(taskId, success, error);
|
||||||
|
lock (_lock) { _inFlight.Remove(taskId); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Write `RefineRunnerTests.cs` (guards, with a fake IClaudeProcess)**
|
||||||
|
|
||||||
|
The test project already has a fake/stub for `IClaudeProcess` used by Prime tests — reuse it (recording invocation + returning a configurable success result). Do NOT spawn the real CLI.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class RefineRunnerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Refuses_when_task_not_idle()
|
||||||
|
{
|
||||||
|
await using var f = new RefineRunnerFixture(); // mirror Prime test fixture wiring
|
||||||
|
var task = await f.SeedTaskAsync(status: TaskStatus.Queued);
|
||||||
|
var outcome = await f.Runner.RefineAsync(task.Id, CancellationToken.None);
|
||||||
|
Assert.False(outcome.Success);
|
||||||
|
Assert.Equal(0, f.Claude.RunCount); // never invoked the CLI
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Idle_task_invokes_claude_once_and_brackets_with_events()
|
||||||
|
{
|
||||||
|
await using var f = new RefineRunnerFixture();
|
||||||
|
var task = await f.SeedTaskAsync(status: TaskStatus.Idle);
|
||||||
|
var outcome = await f.Runner.RefineAsync(task.Id, CancellationToken.None);
|
||||||
|
Assert.True(outcome.Success);
|
||||||
|
Assert.Equal(1, f.Claude.RunCount);
|
||||||
|
Assert.Equal(1, f.Broadcaster.Started);
|
||||||
|
Assert.Equal(1, f.Broadcaster.Finished);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Build the `RefineRunnerFixture`/fakes by copying the Prime test's `IClaudeProcess` stub + real-SQLite `IDbContextFactory` setup and a recording `IRefineBroadcaster`. If a Prime fixture exists, mirror it; otherwise construct inline.
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RefineRunnerTests`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Refine tests/ClaudeDo.Worker.Tests/Refine
|
||||||
|
git commit -m "feat(refine): add RefineRunner, prompt/args helper, and interfaces"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Worker wiring — broadcaster, hub, DI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Program.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement events on `HubBroadcaster`**
|
||||||
|
|
||||||
|
Add `IRefineBroadcaster` to the class's interface list (`public sealed class HubBroadcaster : ..., IRefineBroadcaster`) and add (mirroring the `Prep*` block):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task RefineStarted(string taskId) => _hub.Clients.All.SendAsync("RefineStarted", taskId);
|
||||||
|
public Task RefineFinished(string taskId, bool success, string? error) =>
|
||||||
|
_hub.Clients.All.SendAsync("RefineFinished", taskId, success, error);
|
||||||
|
|
||||||
|
Task IRefineBroadcaster.RefineStartedAsync(string taskId) => RefineStarted(taskId);
|
||||||
|
Task IRefineBroadcaster.RefineFinishedAsync(string taskId, bool success, string? error) =>
|
||||||
|
RefineFinished(taskId, success, error);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `using ClaudeDo.Worker.Refine;`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `RefineTask` to `WorkerHub`**
|
||||||
|
|
||||||
|
`WorkerHub` injects services via its constructor. Add a `private readonly IRefineRunner _refineRunner;` field, add the parameter to the constructor and assign it. Add the method (fire-and-forget; the runner brackets with its own events):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task RefineTask(string taskId)
|
||||||
|
{
|
||||||
|
_ = _refineRunner.RefineAsync(taskId, CancellationToken.None);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `using ClaudeDo.Worker.Refine;`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Register DI in `Program.cs`**
|
||||||
|
|
||||||
|
Near the Prime registrations:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSingleton<IRefineRunner, RefineRunner>();
|
||||||
|
builder.Services.AddSingleton<IRefineBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `using ClaudeDo.Worker.Refine;` if needed. (`HubBroadcaster` is already registered as a singleton — confirm and reuse that registration; do not double-register it.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the worker**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/Program.cs
|
||||||
|
git commit -m "feat(refine): wire RefineTask hub method, broadcaster events, and DI"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: UI worker client — call + events + fakes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
- Modify: test fakes implementing `IWorkerClient`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend the interface**
|
||||||
|
|
||||||
|
In `IWorkerClient.cs` add (near `RunDailyPrepNowAsync` and the `Prep*` events):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task RefineTaskAsync(string taskId);
|
||||||
|
|
||||||
|
event Action<string>? RefineStartedEvent;
|
||||||
|
event Action<string, bool, string?>? RefineFinishedEvent;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement in `WorkerClient`**
|
||||||
|
|
||||||
|
Add the method (mirror `RunDailyPrepNowAsync`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task RefineTaskAsync(string taskId) => _hub.InvokeAsync("RefineTask", taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
Declare the events:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public event Action<string>? RefineStartedEvent;
|
||||||
|
public event Action<string, bool, string?>? RefineFinishedEvent;
|
||||||
|
```
|
||||||
|
|
||||||
|
Subscribe in the constructor (mirror the `Prep*` subscriptions block):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
_hub.On<string>("RefineStarted", id =>
|
||||||
|
Dispatcher.UIThread.Post(() => RefineStartedEvent?.Invoke(id)));
|
||||||
|
_hub.On<string, bool, string?>("RefineFinished", (id, ok, err) =>
|
||||||
|
Dispatcher.UIThread.Post(() => RefineFinishedEvent?.Invoke(id, ok, err)));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update test fakes**
|
||||||
|
|
||||||
|
Find every hand-rolled `IWorkerClient` implementation (search the test projects) and add `RefineTaskAsync` (return `Task.CompletedTask`) plus the two events (`= delegate {}` or `add{}remove{}` no-ops as the fake convention dictates). Build each affected test project.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build UI + test projects**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Then build the UI test project(s). Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs <fake files>
|
||||||
|
git commit -m "feat(ui): add RefineTask client call and refine events"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: UI — icon, button, view model, command
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
|
||||||
|
- Modify: `locales/en.json`, `locales/de.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the `Icon.Refine` geometry**
|
||||||
|
|
||||||
|
In `IslandStyles.axaml`, near the other `Icon.*` `StreamGeometry` resources, add the supplied SVG converted to path data (line-art, rendered stroked via `plan-icon`):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<StreamGeometry x:Key="Icon.Refine">M3,5 L11,5 M3,9 L9,9 M3,13 L7,13 M19,1.8 L19.7,3.9 L21.7,4.6 L19.7,5.3 L19,7.4 L18.3,5.3 L16.3,4.6 L18.3,3.9 Z M18,10.5 L12.2,16.3 M16.6,9.1 L19.4,11.9 M12.2,16.3 L11,18.5 L13.2,17.5 Z</StreamGeometry>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `IsRefining`/`CanRefine` to `TaskRowViewModel`**
|
||||||
|
|
||||||
|
Add the observable property (with the other `[ObservableProperty]` fields):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty] private bool _isRefining;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a computed gate (refine is only offered for Idle, non-parent tasks). Place near other `Can*` getters:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
|
||||||
|
```
|
||||||
|
|
||||||
|
If `Status`/`PlanningPhase`/`IsRefining` are `[ObservableProperty]`, raise `CanRefine` change notifications via partial `On<Prop>Changed` hooks:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
partial void OnStatusChanged(TaskStatus value) => OnPropertyChanged(nameof(CanRefine));
|
||||||
|
partial void OnPlanningPhaseChanged(PlanningPhase value) => OnPropertyChanged(nameof(CanRefine));
|
||||||
|
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
|
||||||
|
```
|
||||||
|
|
||||||
|
> If `On...Changed` partials already exist for `Status`/`PlanningPhase`, add the `OnPropertyChanged(nameof(CanRefine))` line inside them instead of redeclaring.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `RefineTaskCommand` + event wiring to `TasksIslandViewModel`**
|
||||||
|
|
||||||
|
Add the command (mirror an existing per-row command like `ToggleStarCommand`, which takes a `TaskRowViewModel`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RefineTask(TaskRowViewModel row)
|
||||||
|
{
|
||||||
|
if (row is null || !row.CanRefine) return;
|
||||||
|
row.IsRefining = true;
|
||||||
|
try { await _worker.RefineTaskAsync(row.Id); }
|
||||||
|
catch { row.IsRefining = false; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use the same injected worker-client field name this VM already uses (e.g. `_worker`/`_client`). Match it.
|
||||||
|
|
||||||
|
Subscribe to the refine events where the VM wires other worker events (where `OnWorkerTaskUpdated` is subscribed). Add handlers that flip the row flag:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void OnRefineStarted(string taskId)
|
||||||
|
{
|
||||||
|
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
||||||
|
if (row is not null) row.IsRefining = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRefineFinished(string taskId, bool ok, string? error)
|
||||||
|
{
|
||||||
|
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
||||||
|
if (row is not null) row.IsRefining = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire them next to the existing subscriptions (and unsubscribe in the same place the VM unsubscribes others, if it does):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
_worker.RefineStartedEvent += OnRefineStarted;
|
||||||
|
_worker.RefineFinishedEvent += OnRefineFinished;
|
||||||
|
```
|
||||||
|
|
||||||
|
(Content changes—new description/subtasks—arrive through the existing `TaskUpdated` → `OnWorkerTaskUpdated` path; no extra work needed.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the button to `TaskRowView.axaml`**
|
||||||
|
|
||||||
|
Mirror the star button (`Grid.Column="5"` area). Add a refine `icon-btn` (e.g. as a new column or beside the star) bound to the parent ItemsControl's command, passing the row as parameter. Use the `plan-icon` stroked `Path` inside a `Viewbox` (matching the Plan-day button), gate visibility on `CanRefine`, and disable/spin on `IsRefining`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Button Classes="icon-btn refine-btn"
|
||||||
|
IsVisible="{Binding CanRefine}"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.refineTip}">
|
||||||
|
<Viewbox Width="16" Height="16">
|
||||||
|
<Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
|
||||||
|
</Viewbox>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
> Match the column layout already in `TaskRowView.axaml`. If a new grid column is needed, widen `ColumnDefinitions` accordingly and place the refine button left of the star (`Grid.Column`). Keep the existing `vm:` / `loc:` xmlns aliases the file already declares.
|
||||||
|
|
||||||
|
Optionally show a spinning/dimmed state while `IsRefining` (e.g. a style `Selector="Button.refine-btn:disabled"` or bind opacity to `IsRefining`). Keep it simple; a disabled look is enough.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add localization keys**
|
||||||
|
|
||||||
|
Add to both `locales/en.json` and `locales/de.json` under the `tasks` group (keys must stay in parity):
|
||||||
|
|
||||||
|
- en: `"tasks.refineTip": "Refine this task with Claude"`
|
||||||
|
- de: `"tasks.refineTip": "Aufgabe mit Claude verfeinern"`
|
||||||
|
|
||||||
|
> Match the file's actual key structure (flat `"tasks.x"` vs nested `tasks: { x }`)—look at an existing `tasks.*` tooltip key (e.g. the plan-day tip) and follow it exactly.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build UI**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Then run the Localization parity tests: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||||
|
Expected: Build succeeded; locale parity passes.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs locales/en.json locales/de.json
|
||||||
|
git commit -m "feat(ui): add Refine button, icon, and command to task card"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Full build + test sweep, manual smoke
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build all main projects**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
```
|
||||||
|
Expected: Build succeeded for both.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the worker + UI test suites**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
Expected: all green.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual smoke (visual + real CLI — flag to user)**
|
||||||
|
|
||||||
|
Cannot be automated (no real-Claude in tests). Verify by hand: start Worker + UI, on an Idle task click the refine icon → button shows busy → after the run the description improves and steps appear in the Steps card → task stays Idle. Confirm the refine icon is hidden for Queued/Running/Done tasks and for planning parents. **Report this as a visual-verification gap for the user to confirm.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes on parallelism / execution
|
||||||
|
|
||||||
|
- Tasks 1–4 are backend and largely sequential (4 depends on 3). Tasks 1 and 2 are independent and could be done first in either order.
|
||||||
|
- Tasks 5–6 (UI) depend on Task 4's hub/event contract.
|
||||||
|
- Per project convention: subagents use `sonnet`, stage files by explicit path, and do NOT run git/build inside parallel agents — the orchestrator builds, tests, and commits after each task.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Review & Roadblock UX Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** execute task-by-task (subagent-driven-development). Steps use `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** Move the task-row review actions into the Details panel, give the Details panel a real `WaitingForReview` state + a populated diff meter, and add a glanceable yellow roadblock indicator on the task card.
|
||||||
|
|
||||||
|
**Architecture:** Persist a `RoadblockCount` on `TaskEntity` (set by the runner when it folds in `CLAUDEDO_BLOCKED` markers). The row shows a warning badge when count > 0; review controls relocate to `DetailsIslandView`.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, Avalonia, EF Core (one migration), xUnit.
|
||||||
|
|
||||||
|
**Coordination:** A second session (`claudedo-childloop`) is building the child-tasks/improvement-loop in a worktree and will rebase onto main *after* these commits. It also touches `DetailsIslandViewModel`, `TaskRowView.axaml`, `TaskStateService`, `TaskStatus`. This plan deliberately stays OUT of `TaskStateService` and the `TaskStatus` enum (persisting `RoadblockCount` from the runner via the repository instead).
|
||||||
|
|
||||||
|
Build/test (per-project, .NET 8):
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task A — Persist RoadblockCount (Data + Worker, no UI)
|
||||||
|
|
||||||
|
**Files:** `TaskEntity.cs`, `TaskEntityConfiguration.cs`, new migration, `TaskRepository.cs`, `TaskRunner.cs`; test in `tests/ClaudeDo.Data.Tests`.
|
||||||
|
|
||||||
|
- Add `public int RoadblockCount { get; set; }` to `TaskEntity` (default 0).
|
||||||
|
- Map it in `TaskEntityConfiguration` to column `roadblock_count` (default 0). Mirror the pattern used by an existing scalar column (e.g. how `DailyPrepMaxTasks`/other ints are configured).
|
||||||
|
- Create EF migration `AddRoadblockCount` (run `dotnet ef migrations add AddRoadblockCount` against `src/ClaudeDo.Data`; if EF tooling is unavailable, hand-author the migration + Designer + snapshot edit mirroring the most recent migration). One column, default 0, no backfill needed.
|
||||||
|
- Add `TaskRepository.SetRoadblockCountAsync(string taskId, int count, CancellationToken ct)` using `ExecuteUpdateAsync` on `RoadblockCount`.
|
||||||
|
- In `TaskRunner.HandleSuccess`, BEFORE the terminal state write (`SubmitForReviewAsync`/`CompleteAsync`), call `SetRoadblockCountAsync(task.Id, result.Blocks.Count, CancellationToken.None)` so the `TaskUpdated` broadcast reflects it. (Do NOT route this through `TaskStateService`.)
|
||||||
|
- Test: a `TaskRepository` test that sets a count and reads it back.
|
||||||
|
- Commit: `feat(roadblock): persist roadblock count on the task`.
|
||||||
|
|
||||||
|
**Acceptance:** a finished run with N roadblocks leaves `tasks.roadblock_count = N`; a clean run leaves 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task B — Detail panel: host review actions + real WaitingForReview state + diff meter
|
||||||
|
|
||||||
|
**Files:** `DetailsIslandViewModel.cs`, `DetailsIslandView.axaml` (+ `.axaml.cs` if needed), locales if new keys; reuse `IWorkerClient.ApproveReview/RejectReviewToQueue/RejectReviewToIdle/CancelReview` (already exist).
|
||||||
|
|
||||||
|
1. **WaitingForReview state:**
|
||||||
|
- In `StatusToStateKey` map `WaitingForReview => "review"` (was `"running"`); in `FinishedStatusToStateKey` map `"waiting_for_review" => "review"`.
|
||||||
|
- Add `public bool IsWaitingForReview => AgentState == "review";` and raise it in `OnAgentStateChanged`.
|
||||||
|
- Add a `vm.agentStatus.review` locale key (en + de, parity) for the status label.
|
||||||
|
- Confirm `IsAgentSectionEnabled => !IsRunning` still holds (review is no longer "running", so the agent settings section re-enables in review — correct).
|
||||||
|
2. **Review actions (moved from the row):** add commands to `DetailsIslandViewModel` that call the worker for the selected task: `ApproveReviewCommand`, `RejectReviewCommand` (takes feedback text → `RejectReviewToQueueAsync`), `ParkReviewCommand` (`RejectReviewToIdleAsync`), `CancelReviewCommand` (`CancelReviewAsync`). Add a `ReviewFeedback` string property for the rejection comment. Mirror how the row's code-behind currently invokes these (see `TaskRowView.axaml.cs`).
|
||||||
|
- In `DetailsIslandView.axaml`, add a review section (visible when `IsWaitingForReview` and `IsTaskDetailVisible`) with Approve / Reject(+feedback box) / Park / Cancel, reusing the existing `tasks.approve/reject/park/cancel` + `tasks.feedback*` locale keys.
|
||||||
|
3. **Diff meter:** in `RefreshWorktreeAsync`, after setting `row.DiffStat`, parse the `--stat` summary into additions/deletions and assign `DiffAdditions`/`DiffDeletions` (drives `DiffMeterRatio`). Add a small static parser `ParseDiffStat(string?) -> (int add, int del)` reading the "N insertions(+), M deletions(-)" tail; unit-test it.
|
||||||
|
- Commit: `feat(ui): host review actions in the details panel; show review state and diff meter`.
|
||||||
|
|
||||||
|
**Acceptance:** selecting a `WaitingForReview` task shows a "review" status (not "running"), the four review actions work from the detail panel, and the diff meter reflects real additions/deletions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task C — Task row: remove review buttons, add roadblock badge
|
||||||
|
|
||||||
|
**Files:** `TaskRowView.axaml`, `TaskRowView.axaml.cs`, `TaskRowViewModel.cs`; warning icon resource if missing.
|
||||||
|
|
||||||
|
- Remove the review-actions `StackPanel` (lines ~142–157) and the now-unused `RejectAnchor` flyout (~250–279) from `TaskRowView.axaml`, and the corresponding click handlers (`OnApproveReviewClick`, `OnRejectReviewClick`, `OnParkReviewClick`, `OnCancelReviewClick`, reject-flyout handlers) from the code-behind. (Review now lives in the detail panel — Task B.)
|
||||||
|
- `TaskRowViewModel`: add `int RoadblockCount` + `bool HasRoadblock => RoadblockCount > 0` + `string RoadblockTooltip` (e.g. `"{n} roadblock(s) reported — see details"`); map `RoadblockCount` in `FromEntity`.
|
||||||
|
- `TaskRowView.axaml`: add a yellow warning `PathIcon` immediately left of the action area (in the chip row, before the status chip or before the star — pick the spot that reads as "left of the Done/action button"), `IsVisible="{Binding HasRoadblock}"`, `ToolTip.Tip="{Binding RoadblockTooltip}"`. Use a filled-geometry warning icon (PathIcon fills geometry — a stroke path renders invisible); if no `Icon.Warning` resource exists, add one (filled triangle + exclamation) to the icon resources, colored with a yellow/amber brush.
|
||||||
|
- Commit: `feat(ui): roadblock badge on the task card; relocate review actions`.
|
||||||
|
|
||||||
|
**Acceptance:** rows no longer show the four review buttons; a task with `RoadblockCount > 0` shows a yellow ⚠ left of the action button with a tooltip; review still fully works via the detail panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task D — Build + visual-check
|
||||||
|
|
||||||
|
- Full build (`App` + `Worker`) and run Data + Worker test suites; all green.
|
||||||
|
- **Manual (flag for user):** start the app, take a `WaitingForReview` task (the deploy roadblock task qualifies), confirm: row shows the ⚠ badge + no row review buttons; detail panel shows "review" state, working review actions, and a non-zero diff meter for the farewell/README tasks. The agent cannot verify GUI — ask the user.
|
||||||
|
- Then ping `claudedo-childloop` via mailbox with the exact shared-file diffs so it can rebase.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Task Detail Redesign — Component Build Prompts
|
||||||
|
|
||||||
|
Three isolated build tasks (one per component). Each runs in its own worktree off
|
||||||
|
`main`, with the project CLAUDE.md auto-loaded. Full design context lives in
|
||||||
|
`docs/superpowers/specs/2026-06-04-task-detail-redesign-design.md` — every task
|
||||||
|
must read it first.
|
||||||
|
|
||||||
|
Shared rules (all three):
|
||||||
|
- Build a **standalone** `UserControl` + dedicated `ViewModel` that renders fully
|
||||||
|
in the Avalonia previewer via **design-time sample data** (parameterless ctor
|
||||||
|
populating realistic values). Do **not** bind to `DetailsIslandViewModel`.
|
||||||
|
- New files under `src/ClaudeDo.Ui/Views/Islands/Detail/` and
|
||||||
|
`src/ClaudeDo.Ui/ViewModels/Islands/Detail/`.
|
||||||
|
- Use **only** tokens from `Design/Tokens.axaml` and classes from
|
||||||
|
`Design/IslandStyles.axaml`. No inline hex, no magic numbers where a token
|
||||||
|
exists. `PathIcon` fills geometry — stroke-only art is invisible.
|
||||||
|
- Compiled bindings (`x:DataType`). MVVM via CommunityToolkit
|
||||||
|
(`[ObservableProperty]`, `[RelayCommand]`); VM inherits `ViewModelBase`.
|
||||||
|
- **Do NOT modify** `DetailsIslandView.axaml`, `DetailsIslandViewModel.cs`,
|
||||||
|
`AgentStripView`, `SessionTerminalView`, or `TaskRunner.cs`.
|
||||||
|
- Verify: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release` is green.
|
||||||
|
Stage files explicitly by path (never `git add -A`). Commit with a conventional
|
||||||
|
message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 1 — TaskHeaderBar
|
||||||
|
|
||||||
|
(prompt text = task description; see below)
|
||||||
|
|
||||||
|
## TASK 2 — DescriptionStepsCard
|
||||||
|
|
||||||
|
## TASK 3 — WorkConsole
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
# Git Merge/Review — Shared Foundation + Layer A 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:** Build the shared worker conflict contract (so parallel Layer B/C sessions branch from frozen interfaces) and rework the Git tab into a single Approve+merge cockpit.
|
||||||
|
|
||||||
|
**Architecture:** Phase 0 adds the conflict-resolution contract to `IWorkerClient`/`WorkerClient` (real `_hub.InvokeAsync` bodies — the worker hub methods are implemented later by Layer C; calls simply fail at runtime until then) plus client-side DTOs and test-fake updates, then commits + pushes so B and C branch from it. Phase A reworks `WorkConsole.axaml`'s Git tab and routes single-task merge/approve conflicts into a `RequestConflictResolution` seam (wired to Layer C's resolver by the integrator at merge time).
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, SignalR, xUnit. Build individual csproj with `-c Release` (`.slnx` needs .NET 9; a running Worker locks `Debug`).
|
||||||
|
|
||||||
|
**Reference spec:** `docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md`
|
||||||
|
|
||||||
|
**Note on the canonical diff renderer:** the unified diff model/control already exists — `DiffFileViewModel`/`DiffLineViewModel`/`UnifiedDiffParser` (in `src/ClaudeDo.Ui/ViewModels/Modals/`) rendered by `DiffLinesView` (`src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml`). `DiffModalView` and `PlanningDiffView` already use it. So "consolidate diff renderers" for this scope is just verifying that (Task A.3); migrating `WorktreeModalView`'s bespoke diff onto `DiffLinesView` is Layer B's job.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Phase 0 (foundation — pushed before B/C branch):**
|
||||||
|
- Modify `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` — 5 new method signatures.
|
||||||
|
- Modify `src/ClaudeDo.Ui/Services/WorkerClient.cs` — 5 `InvokeAsync` bodies + 3 new DTO records.
|
||||||
|
- Modify `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` — 5 new `virtual` no-op methods.
|
||||||
|
- Modify `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` — 5 new methods on `FakeWorkerClient`.
|
||||||
|
|
||||||
|
**Phase A (Layer A — this session, after foundation commit):**
|
||||||
|
- Modify `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — `RequestConflictResolution` seam; route Approve/Merge conflicts into it.
|
||||||
|
- Modify `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — fuse REVIEW + MERGE sections into one cockpit block.
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (or a sibling test file in the same folder).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Shared Foundation
|
||||||
|
|
||||||
|
### Task 0.1: Add the conflict contract (interface + client + DTOs)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the 5 method signatures to `IWorkerClient`**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, after the existing
|
||||||
|
`Task CancelReviewAsync(string taskId);` line (line 45), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ── Conflict resolution (worker hub side implemented by Layer C) ──
|
||||||
|
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
||||||
|
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
||||||
|
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
||||||
|
Task<MergeResultDto> ContinueMergeAsync(string taskId);
|
||||||
|
Task AbortMergeAsync(string taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the 3 DTO records to `WorkerClient.cs`**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, immediately after line 534
|
||||||
|
(`public record MergeTargetsDto(...)`), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||||
|
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||||
|
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the 5 client method bodies to `WorkerClient.cs`**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, right after the `MergeTaskAsync`
|
||||||
|
method (ends at line 270), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||||
|
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
|
||||||
|
|
||||||
|
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
||||||
|
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", taskId);
|
||||||
|
|
||||||
|
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||||
|
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
|
||||||
|
|
||||||
|
public Task<MergeResultDto> ContinueMergeAsync(string taskId)
|
||||||
|
=> _hub.InvokeAsync<MergeResultDto>("ContinueMerge", taskId);
|
||||||
|
|
||||||
|
public Task AbortMergeAsync(string taskId)
|
||||||
|
=> _hub.InvokeAsync("AbortMerge", taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the UI project**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release`
|
||||||
|
Expected: build FAILS — the two test projects won't compile yet, but the UI project
|
||||||
|
itself should succeed. If the UI project reports "does not implement interface member"
|
||||||
|
it means a body is missing; fix before continuing. (Test projects are fixed in 0.2.)
|
||||||
|
|
||||||
|
### Task 0.2: Update the hand-rolled test fakes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
|
||||||
|
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add 5 virtual no-ops to `StubWorkerClient`**
|
||||||
|
|
||||||
|
In `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, after the `MergeTaskAsync` override
|
||||||
|
(line 57), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public virtual Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
||||||
|
public virtual Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
||||||
|
public virtual Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
||||||
|
public virtual Task<MergeResultDto> ContinueMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||||
|
public virtual Task AbortMergeAsync(string taskId) => Task.CompletedTask;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add 5 methods to `FakeWorkerClient`**
|
||||||
|
|
||||||
|
In `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`, after the
|
||||||
|
`MergeTaskAsync` method (line 47), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
||||||
|
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
||||||
|
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
||||||
|
public Task<MergeResultDto> ContinueMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||||
|
public Task AbortMergeAsync(string taskId) => Task.CompletedTask;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build both test projects**
|
||||||
|
|
||||||
|
Run: `dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release && dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
|
||||||
|
Expected: both BUILD succeed.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the UI test suite to confirm green baseline**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||||
|
Expected: PASS (no behavior changed yet).
|
||||||
|
|
||||||
|
### Task 0.3: Commit and push the foundation
|
||||||
|
|
||||||
|
- [ ] **Step 1: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
|
||||||
|
git commit -m "feat(ui): add conflict-resolution worker contract (foundation for merge rework)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Push so Layer B/C can branch from this commit**
|
||||||
|
|
||||||
|
Run: `git push`
|
||||||
|
Expected: pushed to `main`. (First push to git.kuns.dev may fail auth — retry once.)
|
||||||
|
**This commit is the branch point for the Layer B and Layer C kickoff prompts.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A — Layer A Review/Merge Cockpit
|
||||||
|
|
||||||
|
### Task A.1: Conflict-resolution seam + route Approve/Merge conflicts into it (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs` (new)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs`. Mirror
|
||||||
|
the VM-construction harness used in
|
||||||
|
`tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (same folder) —
|
||||||
|
construct `DetailsIslandViewModel` exactly as that file does, including its
|
||||||
|
`StubWorkerClient` subclass pattern. The test:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveReview_OnConflict_InvokesConflictResolutionSeam()
|
||||||
|
{
|
||||||
|
string? resolvedTaskId = null;
|
||||||
|
string? resolvedTarget = null;
|
||||||
|
|
||||||
|
// Construct the VM as in DetailsIslandPlanningTests, with a worker stub whose
|
||||||
|
// ApproveReviewAsync returns a conflict result:
|
||||||
|
// public override Task<MergeResultDto?> ApproveReviewAsync(string id, string target)
|
||||||
|
// => Task.FromResult<MergeResultDto?>(new MergeResultDto("conflict", new[]{"a.cs"}, null));
|
||||||
|
var vm = CreateVm(/* worker stub above */);
|
||||||
|
vm.RequestConflictResolution = (taskId, target) =>
|
||||||
|
{
|
||||||
|
resolvedTaskId = taskId; resolvedTarget = target;
|
||||||
|
return System.Threading.Tasks.Task.CompletedTask;
|
||||||
|
};
|
||||||
|
// assign a task in WaitingForReview + a SelectedMergeTarget = "main" via the same
|
||||||
|
// helpers DetailsIslandPlanningTests uses.
|
||||||
|
|
||||||
|
await vm.ApproveReviewCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal(/* the seeded task id */, resolvedTaskId);
|
||||||
|
Assert.Equal("main", resolvedTarget);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter ApproveReview_OnConflict_InvokesConflictResolutionSeam`
|
||||||
|
Expected: FAIL — `RequestConflictResolution` property does not exist (compile error).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the seam property**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, beside the other
|
||||||
|
view-wired delegates (`ShowDiffModal`, `ShowMergeModal` around line 387–390), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Invoked when a single-task merge/approve hits a conflict. Wired by the
|
||||||
|
// integrator to Layer C's conflict resolver. Args: (taskId, targetBranch).
|
||||||
|
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Route the Approve conflict branch into the seam**
|
||||||
|
|
||||||
|
In `ApproveReviewAsync` (around line 1453), replace the conflict branch body so it
|
||||||
|
prefers the seam, falling back to the current preview-text behavior:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
|
||||||
|
if (result?.Status == "conflict")
|
||||||
|
{
|
||||||
|
if (RequestConflictResolution is not null)
|
||||||
|
{
|
||||||
|
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||||
|
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Route the manual Merge conflict branch into the seam**
|
||||||
|
|
||||||
|
In `MergeAsync` (around line 1170), apply the same pattern to its conflict branch:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
|
||||||
|
if (result.Status == "conflict")
|
||||||
|
{
|
||||||
|
if (RequestConflictResolution is not null)
|
||||||
|
{
|
||||||
|
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||||
|
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await RefreshMergePreviewAsync();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter ApproveReview_OnConflict_InvokesConflictResolutionSeam`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run the full UI suite (no regressions)**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs
|
||||||
|
git commit -m "feat(ui): route single-task merge conflicts into a resolution seam"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task A.2: Fuse the Git tab into one Approve+merge cockpit
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the two Git-tab sections with one cockpit block**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, replace the entire Git
|
||||||
|
`ScrollViewer` body (lines 255–313 — the `<!-- Git: ... -->` block containing the
|
||||||
|
separate `REVIEW` `StackPanel` and the `MERGE & WORKTREE` `StackPanel`) with a single
|
||||||
|
cockpit where Approve sits with the merge target/preview/actions. Keep the existing
|
||||||
|
control class names (`section-label`, `field-label`, `btn`, `btn accent`, `meta`) and
|
||||||
|
the existing bindings (`SelectedMergeTarget`, `MergeTargetBranches`, `MergePreviewText`,
|
||||||
|
`MergeIsClean`, `MergeIsConflict`, `ShowMergePreviewMuted`, `OpenDiffCommand`,
|
||||||
|
`ApproveReviewCommand`, `MergeCommand`, `ShowSingleMerge`, `OpenWorktreeCommand`,
|
||||||
|
`ReviewCombinedDiffCommand`, `MergeAllCommand`, `CanMergeAll`, `MergeAllDisabledReason`,
|
||||||
|
`MergeAllError`):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- Git: one Approve + merge cockpit -->
|
||||||
|
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
|
||||||
|
<StackPanel Spacing="12" IsVisible="{Binding ShowMergeSection}">
|
||||||
|
<TextBlock Classes="section-label" Text="MERGE" />
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="field-label" Text="Target branch" />
|
||||||
|
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
||||||
|
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
||||||
|
HorizontalAlignment="Stretch" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource MossBrush}"
|
||||||
|
IsVisible="{Binding MergeIsClean}" />
|
||||||
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
|
IsVisible="{Binding MergeIsConflict}" />
|
||||||
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
IsVisible="{Binding ShowMergePreviewMuted}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Primary action: Approve flows straight into the merge.
|
||||||
|
Approve is the review-gated path; the plain Merge button covers
|
||||||
|
already-reviewed / kept worktrees. -->
|
||||||
|
<WrapPanel Orientation="Horizontal">
|
||||||
|
<Button Classes="btn accent" Content="Approve & Merge" Margin="0,0,8,8"
|
||||||
|
Command="{Binding ApproveReviewCommand}"
|
||||||
|
IsVisible="{Binding IsWaitingForReview}" />
|
||||||
|
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
|
||||||
|
Command="{Binding MergeCommand}"
|
||||||
|
IsVisible="{Binding ShowSingleMerge}" />
|
||||||
|
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
|
||||||
|
Command="{Binding OpenDiffCommand}" />
|
||||||
|
<Button Classes="btn" Margin="0,0,8,8"
|
||||||
|
Command="{Binding OpenWorktreeCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||||
|
<TextBlock Text="Worktree" />
|
||||||
|
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
|
||||||
|
Command="{Binding ReviewCombinedDiffCommand}" />
|
||||||
|
<Button Classes="btn accent" Content="Merge All Subtasks" Margin="0,0,0,8"
|
||||||
|
Command="{Binding MergeAllCommand}"
|
||||||
|
IsEnabled="{Binding CanMergeAll}"
|
||||||
|
ToolTip.Tip="{Binding MergeAllDisabledReason}" />
|
||||||
|
</WrapPanel>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding MergeAllError}"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding MergeAllError,
|
||||||
|
Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the cockpit now shows whenever `ShowMergeSection` is true. `ShowMergeSection`
|
||||||
|
(DetailsIslandViewModel line 161) must be true while `IsWaitingForReview` so the
|
||||||
|
Approve button appears. Check its expression in Step 2.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify `ShowMergeSection` covers the review state**
|
||||||
|
|
||||||
|
Read `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` line 161. If
|
||||||
|
`ShowMergeSection` is false while `IsWaitingForReview` (e.g. it requires a non-review
|
||||||
|
state), widen it to also be true when `IsWaitingForReview && WorktreePath != null`, and
|
||||||
|
ensure `OnPropertyChanged(nameof(ShowMergeSection))` already fires on the relevant state
|
||||||
|
transitions (it is notified via `NotifySessionSections`). Make the minimal change needed
|
||||||
|
so the Approve button is visible in review state. If it already covers review, change
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the app project**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: BUILD succeeds (pulls in Ui + Data).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Visual verification (manual — flag for the user)**
|
||||||
|
|
||||||
|
This is an AXAML layout change with no automated coverage. Launch the app, open a task
|
||||||
|
in `WaitingForReview`, open the Git tab, and confirm: the single MERGE block shows the
|
||||||
|
target combo, the colored preview line, an "Approve & Merge" button (review state), and
|
||||||
|
the diff/worktree/combined/merge-all actions. **Explicitly tell the user this needs a
|
||||||
|
visual pass — do not claim it works without running it.**
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
|
||||||
|
git commit -m "feat(ui): fuse git tab into one approve+merge cockpit"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task A.3: Verify diff-renderer consolidation
|
||||||
|
|
||||||
|
**Files:** none modified (verification only).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm DiffModal + Planning already use the canonical renderer**
|
||||||
|
|
||||||
|
Run: `rg -l "DiffLinesView" src/ClaudeDo.Ui/Views`
|
||||||
|
Expected: matches in `Modals/DiffModalView.axaml` and `Planning/PlanningDiffView.axaml`.
|
||||||
|
If `PlanningDiffView.axaml` does NOT use `DiffLinesView`, change its diff `ItemsControl`
|
||||||
|
to a `<controls:DiffLinesView Lines="{Binding SelectedFile.Lines}" />` (matching
|
||||||
|
`DiffModalView.axaml`'s usage) and rebuild the App project. If both already use it, this
|
||||||
|
task is a no-op — record that and move on. (`WorktreeModalView`'s bespoke diff is
|
||||||
|
intentionally left for Layer B.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- **Spec coverage:** Foundation contract (spec §"Frozen worker conflict contract") →
|
||||||
|
Task 0.1. Test fakes (spec parallel-boundaries row) → Task 0.2. Branch point (spec
|
||||||
|
§"built & pushed this session") → Task 0.3. Layer A cockpit + Approve/merge flow
|
||||||
|
together (spec §"Layer A") → Task A.2. Single-task approve-on-conflict opens resolver
|
||||||
|
via seam (spec §"Layer A" + §"integration seams") → Task A.1. Diff consolidation
|
||||||
|
(spec §"One diff model") → Task A.3. Output-footer feedback unchanged → not touched
|
||||||
|
(correct). No spec requirement left unmapped for this session's scope.
|
||||||
|
- **Placeholder scan:** none — every code step has concrete code; the only "mirror the
|
||||||
|
existing harness" reference (Task A.1 Step 1) points at a real file with a working
|
||||||
|
pattern, not a TODO.
|
||||||
|
- **Type consistency:** `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` and the
|
||||||
|
5 method names match between `IWorkerClient` (0.1 Step 1), `WorkerClient` (0.1 Steps
|
||||||
|
2–3), and both fakes (0.2). The seam `RequestConflictResolution` is
|
||||||
|
`Func<string,string,Task>?` everywhere (A.1 Steps 1, 3–5). DTO field names match the
|
||||||
|
spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration notes (for the integrator merging A + B + C)
|
||||||
|
|
||||||
|
- Wire `DetailsIslandViewModel.RequestConflictResolution` and Layer B's equivalent
|
||||||
|
callback to Layer C's `ConflictResolverViewModel` factory + `ShowConflictResolver`
|
||||||
|
dialog delegate.
|
||||||
|
- Layer C implements the worker hub methods `StartConflictMerge`, `GetMergeConflicts`,
|
||||||
|
`WriteConflictResolution`, `ContinueMerge`, `AbortMerge`; the client side from Task
|
||||||
|
0.1 already calls them by name.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Git Merge/Review Rework — Parallel Kickoff Prompts (Layer B & Layer C)
|
||||||
|
|
||||||
|
These are self-contained prompts to paste into two fresh ClaudeDo sessions, each in its
|
||||||
|
own git worktree, run **in parallel** with the main session's Layer A work.
|
||||||
|
|
||||||
|
**Prerequisite — branch point:** Both sessions must branch from `main` **at or after**
|
||||||
|
the foundation commit `feat(ui): add conflict-resolution worker contract (foundation for
|
||||||
|
merge rework)` (Phase 0, Task 0.3 of
|
||||||
|
`docs/superpowers/plans/2026-06-05-git-merge-review-foundation-layerA.md`). That commit
|
||||||
|
adds the frozen `IWorkerClient` conflict contract both layers rely on. Do not start B/C
|
||||||
|
until that commit is pushed.
|
||||||
|
|
||||||
|
**Integration:** Neither session pushes to `main` or merges. Each leaves its branch/
|
||||||
|
worktree for the orchestrator (the main session) to review and merge.
|
||||||
|
|
||||||
|
Design reference for both: `docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer B — Multi-worktree merge cockpit
|
||||||
|
|
||||||
|
```
|
||||||
|
We're reworking ClaudeDo's merge/review UX. Your job is Layer B: a multi-worktree merge
|
||||||
|
cockpit. The overall design is in docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md
|
||||||
|
(read the "Layer B" section and "Parallel boundaries" table first). A shared foundation
|
||||||
|
commit ("add conflict-resolution worker contract") is already on main — branch from it.
|
||||||
|
|
||||||
|
First, create an isolated worktree for this work (use the superpowers:using-git-worktrees
|
||||||
|
skill). Then write a plan (superpowers:writing-plans) for just Layer B and implement it
|
||||||
|
with superpowers:subagent-driven-development (sonnet subagents, TDD, commit per task).
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Rework WorktreesOverviewModalView + WorktreesOverviewModalViewModel into a batch-merge
|
||||||
|
cockpit: list mergeable worktrees, multi-select N, pick ONE target branch, "Merge all".
|
||||||
|
- Skip-and-continue: loop the EXISTING IWorkerClient.MergeTaskAsync(taskId, target,
|
||||||
|
removeWorktree:false, msg) over the selected tasks. Clean ones merge; conflicting ones
|
||||||
|
(MergeTaskAsync returns Status=="conflict", auto-aborts leaving the tree clean) are
|
||||||
|
collected into a "needs resolution" list shown with live progress.
|
||||||
|
- Each conflict row gets a "Resolve" button that invokes a seam:
|
||||||
|
public Func<string, string, Task>? RequestConflictResolution { get; set; } // (taskId, targetBranch)
|
||||||
|
Define this callback property on the cockpit VM; leave it unwired (the orchestrator
|
||||||
|
wires it to Layer C's resolver at merge time). Do NOT reference any ConflictResolver
|
||||||
|
type.
|
||||||
|
- Migrate WorktreeModalView's bespoke inline diff onto the canonical DiffLinesView
|
||||||
|
control (src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml) using DiffFileViewModel/
|
||||||
|
DiffLineViewModel/UnifiedDiffParser (src/ClaudeDo.Ui/ViewModels/Modals/). This removes
|
||||||
|
the last duplicate diff renderer.
|
||||||
|
|
||||||
|
Reuse these existing IWorkerClient methods (already implemented): MergeTaskAsync,
|
||||||
|
GetMergeTargetsAsync, GetWorktreesOverviewAsync, SetWorktreeStateAsync,
|
||||||
|
CleanupFinishedWorktreesAsync, ForceRemoveWorktreeAsync.
|
||||||
|
|
||||||
|
Do NOT touch (other layers own them): any worker-side files (WorkerHub, TaskMergeService,
|
||||||
|
GitService), IWorkerClient.cs / WorkerClient.cs, WorkConsole.axaml,
|
||||||
|
DetailsIslandViewModel.cs, or create the ConflictResolver UI.
|
||||||
|
|
||||||
|
Build with: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release (a running
|
||||||
|
Worker locks Debug — use Release). Keep locales/en.json and de.json keys in parity if you
|
||||||
|
add any. If you change IWorkerClient (you shouldn't need to), update the hand-rolled fakes
|
||||||
|
in tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs and
|
||||||
|
tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs. No tests that spawn
|
||||||
|
the real claude CLI.
|
||||||
|
|
||||||
|
Commit per task with Conventional Commits. Do NOT push to main and do NOT merge — leave
|
||||||
|
your worktree/branch for the orchestrator. Flag any AXAML layout for visual verification
|
||||||
|
rather than claiming it works.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer C — Inline conflict resolver
|
||||||
|
|
||||||
|
```
|
||||||
|
We're reworking ClaudeDo's merge/review UX. Your job is Layer C: an in-app, VSCode-style
|
||||||
|
inline conflict resolver, plus the worker plumbing it needs. The overall design is in
|
||||||
|
docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md (read the "Layer C",
|
||||||
|
"Frozen worker conflict contract", and "Parallel boundaries" sections first). A shared
|
||||||
|
foundation commit ("add conflict-resolution worker contract") is already on main — branch
|
||||||
|
from it. That commit already wired the CLIENT side (IWorkerClient + WorkerClient call
|
||||||
|
these hub methods by name); your job includes implementing the matching WORKER hub methods.
|
||||||
|
|
||||||
|
First, create an isolated worktree (superpowers:using-git-worktrees). Then write a plan
|
||||||
|
(superpowers:writing-plans) for Layer C and implement it with
|
||||||
|
superpowers:subagent-driven-development (sonnet subagents, TDD, commit per task).
|
||||||
|
|
||||||
|
Worker side — implement these 5 hub methods in WorkerHub (names/params/returns MUST match
|
||||||
|
the client calls already shipped in the foundation):
|
||||||
|
- StartConflictMerge(string taskId, string targetBranch) -> MergeResultDto
|
||||||
|
Calls TaskMergeService.MergeAsync with leaveConflictsInTree:true (the overload/flag
|
||||||
|
already exists — used today by PlanningMergeOrchestrator). Leaves .git/MERGE_HEAD in
|
||||||
|
the list's WorkingDir, returns Status="conflict" + conflict file list.
|
||||||
|
- GetMergeConflicts(string taskId) -> MergeConflictsDto
|
||||||
|
For each conflicted file (git diff --name-only --diff-filter=U), read ours/theirs/base
|
||||||
|
via `git show :2:<path>` / `:3:<path>` / `:1:<path>`. Add GitService helpers as needed.
|
||||||
|
- WriteConflictResolution(string taskId, string path, string resolvedContent) -> void
|
||||||
|
Write resolvedContent to the file in WorkingDir and `git add` it.
|
||||||
|
- ContinueMerge(string taskId) -> MergeResultDto
|
||||||
|
Wrap the EXISTING TaskMergeService.ContinueMergeAsync (git add -A → re-check
|
||||||
|
diff --diff-filter=U → git commit). Currently service-level only; expose it on the hub.
|
||||||
|
- AbortMerge(string taskId) -> void
|
||||||
|
Wrap the EXISTING TaskMergeService.AbortMergeAsync (git merge --abort).
|
||||||
|
|
||||||
|
Define worker-side DTO records that serialize identically to the client records already in
|
||||||
|
WorkerClient.cs:
|
||||||
|
MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files)
|
||||||
|
ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks)
|
||||||
|
ConflictHunkDto(string Ours, string Theirs, string? Base)
|
||||||
|
(place beside the other hub DTOs in WorkerHub.cs). MergeResultDto already exists.
|
||||||
|
|
||||||
|
UI side — new files only:
|
||||||
|
- ConflictResolverViewModel + ConflictResolverView. On open: StartConflictMergeAsync then
|
||||||
|
GetMergeConflictsAsync(taskId). Per conflict hunk show ours vs theirs stacked with
|
||||||
|
buttons Accept Current / Accept Incoming / Accept Both / Edit manually, plus a free-text
|
||||||
|
box for the merged result of that hunk. Use the UI conflict model from the design
|
||||||
|
(ConflictFile { Path, Hunks[] }, ConflictHunk { Ours, Theirs, Base, Resolution }) —
|
||||||
|
shape it so a future 3-way pane needs no model change.
|
||||||
|
- When every file is resolved: WriteConflictResolutionAsync per file, then
|
||||||
|
ContinueMergeAsync(taskId) (Status "merged" closes; "conflict" means not fully resolved,
|
||||||
|
stay open). AbortMergeAsync(taskId) cancels.
|
||||||
|
- Expose a factory Func<string, ConflictResolverViewModel> and a
|
||||||
|
Func<ConflictResolverViewModel, Task> ShowConflictResolver dialog delegate for the
|
||||||
|
orchestrator to wire to Layer A/B's RequestConflictResolution(taskId, target) seams.
|
||||||
|
|
||||||
|
Do NOT touch (other layers own them): WorkerClient.cs, IWorkerClient.cs (already wired),
|
||||||
|
WorkConsole.axaml, DetailsIslandViewModel.cs, WorktreesOverviewModalView/VM. You WILL need
|
||||||
|
to add the 5 worker hub methods + GitService conflict reads.
|
||||||
|
|
||||||
|
Tests: add worker tests for the conflict reads / continue / abort using real SQLite + real
|
||||||
|
git (follow existing GitService/TaskMergeService test patterns). NEVER spawn the real
|
||||||
|
claude CLI. If you change IWorkerClient (you should NOT — client is frozen), update the
|
||||||
|
fakes in both test projects.
|
||||||
|
|
||||||
|
Build with: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release and
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release (a running Worker locks
|
||||||
|
Debug). Keep locales/en.json and de.json in parity for any new UI strings.
|
||||||
|
|
||||||
|
Commit per task with Conventional Commits. Do NOT push to main and do NOT merge — leave
|
||||||
|
your worktree/branch for the orchestrator. Flag the resolver UI for visual verification.
|
||||||
|
```
|
||||||
@@ -0,0 +1,920 @@
|
|||||||
|
# Layer C — Inline Conflict Resolver 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:** Build the worker-side conflict plumbing (5 frozen hub methods + GitService reads) and a VSCode-style in-app inline conflict resolver UI for ClaudeDo's merge rework.
|
||||||
|
|
||||||
|
**Architecture:** The worker performs a real merge that leaves conflicts in the list's working tree (`leaveConflictsInTree:true`), exposes ours/theirs/base per conflicted file via `git show :2:/:3:/:1:`, accepts written resolutions, and finishes via the existing `ContinueMergeAsync`/`AbortMergeAsync`. The UI presents each conflicted file's hunk with Accept Current/Incoming/Both/Edit-manually controls plus a free-text merged box, then writes resolutions and continues.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, ASP.NET Core SignalR (WorkerHub), EF Core/SQLite, Avalonia MVVM (CommunityToolkit), xUnit + real git/SQLite fixtures.
|
||||||
|
|
||||||
|
**Frozen client contract (already shipped in foundation commit `2dfc455`, DO NOT edit):**
|
||||||
|
- `IWorkerClient` / `WorkerClient.cs` already call hub methods by name: `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`, `ContinueMerge`, `AbortMerge`.
|
||||||
|
- Client DTOs already exist in `WorkerClient.cs`: `MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files)`, `ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks)`, `ConflictHunkDto(string Ours, string Theirs, string? Base)`, plus existing `MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)`.
|
||||||
|
- Worker-side DTOs must serialize identically (same record shape) and live in `WorkerHub.cs`.
|
||||||
|
|
||||||
|
**Do NOT touch:** `WorkerClient.cs`, `Interfaces/IWorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, `WorktreesOverviewModalView/VM`, `WorktreeModalView`. Test fakes for `IWorkerClient` already implement the 5 methods as no-op stubs (`StubWorkerClient` is `virtual` in Ui.Tests) — subclass/override, never edit the interface.
|
||||||
|
|
||||||
|
**Build/test commands (.NET 8 — running Worker locks `Debug`, always `-c Release`):**
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Worker / Data (create + modify):**
|
||||||
|
- Modify `src/ClaudeDo.Data/Git/GitService.cs` — add `ShowStageAsync` (untrimmed blob read) + `AddPathAsync`; add `trimOutput` param to `RunGitAsync`.
|
||||||
|
- Modify `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` — add records `MergeConflicts`/`ConflictFileContent`; add `GetConflictsAsync` + `WriteResolutionAsync`.
|
||||||
|
- Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add DTOs `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` + 5 hub methods.
|
||||||
|
|
||||||
|
**UI (create new only):**
|
||||||
|
- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs` — `ConflictFile`, `ConflictHunk`.
|
||||||
|
- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs`.
|
||||||
|
- Create `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml` + `.axaml.cs`.
|
||||||
|
|
||||||
|
**Wiring (modify):**
|
||||||
|
- Modify `src/ClaudeDo.App/Program.cs` — register `ConflictResolverViewModel` + `Func<string, ConflictResolverViewModel>`.
|
||||||
|
- Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — additive seam (`ConflictResolverFactory`, `ShowConflictResolver`, `RequestConflictResolutionAsync`).
|
||||||
|
- Modify `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowConflictResolver` dialog delegate.
|
||||||
|
- Modify `src/ClaudeDo.Localization/locales/en.json` + `de.json` — `conflictResolver.*` keys (parity enforced by Localization.Tests).
|
||||||
|
|
||||||
|
**Tests (create + modify):**
|
||||||
|
- Modify `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` — conflict-read / write-resolution / round-trip tests.
|
||||||
|
- Create `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: GitService conflict-blob reads
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/Git/GitService.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` (GitService exercised here via real repo; add focused tests in Task 2 round-trip)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `trimOutput` param to `RunGitAsync`** so blob reads keep exact bytes.
|
||||||
|
|
||||||
|
In `RunGitAsync` signature add `bool trimOutput = true`, and change the return to:
|
||||||
|
```csharp
|
||||||
|
return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
|
||||||
|
```
|
||||||
|
(All existing callers keep the default `true`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `ShowStageAsync` + `AddPathAsync`** (place after `ListConflictedFilesAsync`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a conflicted file's blob at a merge stage: 1=base, 2=ours, 3=theirs.
|
||||||
|
/// Returns null when the stage doesn't exist (e.g. add/add conflict has no base).
|
||||||
|
/// Output is NOT trimmed so file content round-trips exactly.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> ShowStageAsync(string repoDir, int stage, string path, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false);
|
||||||
|
return exitCode == 0 ? stdout : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the Data + Worker projects to verify compilation.**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/Git/GitService.cs
|
||||||
|
git commit -m "feat(git): add conflict-stage blob reads and single-path staging"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: TaskMergeService conflict reads + resolution writes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests** (append inside `TaskMergeServiceTests`, before `#region Test doubles`). Reuse the existing helpers `SeedListAndTask`, `SeedWorktree`, `BuildService`, and the `GitRepoFixture` conflict setup pattern from `ContinueMergeAsync_AfterUserResolves...`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||||||
|
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c1", wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||||||
|
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||||
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/c1", repo.BaseCommit);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var start = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
Assert.Equal(TaskMergeService.StatusConflict, start.Status);
|
||||||
|
|
||||||
|
var conflicts = await svc.GetConflictsAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(task.Id, conflicts.TaskId);
|
||||||
|
var file = Assert.Single(conflicts.Files);
|
||||||
|
Assert.Equal("README.md", file.Path);
|
||||||
|
Assert.Contains("main change", file.Ours); // ours = target (main) side after checkout
|
||||||
|
Assert.Contains("branch change", file.Theirs); // theirs = merged-in branch
|
||||||
|
Assert.NotNull(file.Base);
|
||||||
|
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteResolutionAsync_ThenContinue_CompletesMerge()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||||||
|
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c2", wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||||||
|
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||||
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/c2", repo.BaseCommit);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
|
||||||
|
await svc.WriteResolutionAsync(task.Id, "README.md", "# resolved by user\n", CancellationToken.None);
|
||||||
|
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||||
|
Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
|
||||||
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail** (no such methods).
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"`
|
||||||
|
Expected: compile error / FAIL (methods don't exist).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add records + methods to `TaskMergeService.cs`.**
|
||||||
|
|
||||||
|
Add records beside `MergeResult` (top of file, after the existing record declarations):
|
||||||
|
```csharp
|
||||||
|
public sealed record MergeConflicts(
|
||||||
|
string TaskId,
|
||||||
|
IReadOnlyList<ConflictFileContent> Files);
|
||||||
|
|
||||||
|
public sealed record ConflictFileContent(
|
||||||
|
string Path,
|
||||||
|
string Ours,
|
||||||
|
string Theirs,
|
||||||
|
string? Base);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add methods inside the class (after `AbortMergeAsync`):
|
||||||
|
```csharp
|
||||||
|
public async Task<MergeConflicts> GetConflictsAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
|
throw new InvalidOperationException("list has no working directory");
|
||||||
|
|
||||||
|
var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
|
||||||
|
var result = new List<ConflictFileContent>(files.Count);
|
||||||
|
foreach (var path in files)
|
||||||
|
{
|
||||||
|
var ours = await _git.ShowStageAsync(list.WorkingDir, 2, path, ct) ?? "";
|
||||||
|
var theirs = await _git.ShowStageAsync(list.WorkingDir, 3, path, ct) ?? "";
|
||||||
|
var @base = await _git.ShowStageAsync(list.WorkingDir, 1, path, ct);
|
||||||
|
result.Add(new ConflictFileContent(path, ours, theirs, @base));
|
||||||
|
}
|
||||||
|
return new MergeConflicts(taskId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
|
throw new InvalidOperationException("list has no working directory");
|
||||||
|
|
||||||
|
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
await File.WriteAllTextAsync(full, content, ct);
|
||||||
|
await _git.AddPathAsync(list.WorkingDir, path, ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Note: `Path` is `System.IO.Path` — the file already uses it via other helpers; the record property `Path` does not shadow it inside these methods because it's accessed as a static type, not an instance member.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests to verify they pass.**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"`
|
||||||
|
Expected: PASS (2 tests). If git unavailable they no-op.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
|
||||||
|
git commit -m "feat(merge): read conflict stages and write user resolutions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: WorkerHub conflict methods + DTOs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add DTOs** beside the existing merge DTOs (after `public record MergeTargetsDto(...)`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||||
|
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||||
|
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the 5 hub methods** (after `PreviewMerge`). Names/params/returns MUST match the frozen client calls.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<MergeResultDto> StartConflictMerge(string taskId, string targetBranch)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var r = await _mergeService.MergeAsync(
|
||||||
|
taskId, targetBranch ?? "", removeWorktree: false, "Merge task",
|
||||||
|
leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
|
throw new HubException(r.ErrorMessage ?? "merge blocked");
|
||||||
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task<MergeConflictsDto> GetMergeConflicts(string taskId)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var c = await _mergeService.GetConflictsAsync(taskId, CancellationToken.None);
|
||||||
|
return new MergeConflictsDto(
|
||||||
|
c.TaskId,
|
||||||
|
c.Files.Select(f => new ConflictFileDto(
|
||||||
|
f.Path,
|
||||||
|
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
|
||||||
|
=> HubGuard(() => _mergeService.WriteResolutionAsync(
|
||||||
|
taskId, path, resolvedContent ?? "", CancellationToken.None));
|
||||||
|
|
||||||
|
public Task<MergeResultDto> ContinueMerge(string taskId)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
|
||||||
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
|
throw new HubException(r.ErrorMessage ?? "continue failed");
|
||||||
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task AbortMerge(string taskId)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
|
||||||
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
|
throw new HubException(r.ErrorMessage ?? "abort failed");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the Worker project to verify compilation.**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
|
||||||
|
git commit -m "feat(hub): expose conflict-resolution merge methods"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Conflict UI model
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs` (model tests added here in Task 5; this task is build-verified)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the model file.** Shaped so a 3-way pane needs no model change (`Base` retained per hunk).
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
|
||||||
|
public sealed partial class ConflictHunk : ObservableObject
|
||||||
|
{
|
||||||
|
public string Ours { get; }
|
||||||
|
public string Theirs { get; }
|
||||||
|
public string? Base { get; }
|
||||||
|
|
||||||
|
[ObservableProperty] private string? _resolution;
|
||||||
|
|
||||||
|
public bool IsResolved => Resolution is not null;
|
||||||
|
|
||||||
|
public ConflictHunk(string ours, string theirs, string? @base)
|
||||||
|
{
|
||||||
|
Ours = ours;
|
||||||
|
Theirs = theirs;
|
||||||
|
Base = @base;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
|
||||||
|
|
||||||
|
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
|
||||||
|
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
|
||||||
|
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
|
||||||
|
[RelayCommand] private void EditManually() => Resolution ??= Ours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ConflictFile
|
||||||
|
{
|
||||||
|
public string Path { get; }
|
||||||
|
public IReadOnlyList<ConflictHunk> Hunks { get; }
|
||||||
|
|
||||||
|
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
Hunks = hunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
|
||||||
|
|
||||||
|
/// <summary>The merged file content: concatenation of each hunk's resolution
|
||||||
|
/// (single whole-file hunk today; concatenation keeps it correct for multi-hunk later).</summary>
|
||||||
|
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the Ui project to verify compilation.**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
|
||||||
|
git commit -m "feat(ui): add inline conflict model (file/hunk with resolution)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: ConflictResolverViewModel
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests.** Subclass the existing `StubWorkerClient` (its conflict methods are `virtual`).
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class ConflictResolverViewModelTests
|
||||||
|
{
|
||||||
|
private sealed class FakeWorker : StubWorkerClient
|
||||||
|
{
|
||||||
|
public string? WrittenPath;
|
||||||
|
public string? WrittenContent;
|
||||||
|
public bool Continued;
|
||||||
|
public bool Aborted;
|
||||||
|
public string ContinueStatus = "merged";
|
||||||
|
|
||||||
|
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||||
|
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
|
||||||
|
|
||||||
|
public override Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
||||||
|
=> Task.FromResult(new MergeConflictsDto(taskId, new[]
|
||||||
|
{
|
||||||
|
new ConflictFileDto("README.md", new[] { new ConflictHunkDto("ours\n", "theirs\n", "base\n") })
|
||||||
|
}));
|
||||||
|
|
||||||
|
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||||
|
{
|
||||||
|
WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<MergeResultDto> ContinueMergeAsync(string taskId)
|
||||||
|
{
|
||||||
|
Continued = true;
|
||||||
|
return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty<string>(), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task AbortMergeAsync(string taskId) { Aborted = true; return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
|
||||||
|
{
|
||||||
|
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
|
||||||
|
var hasConflicts = await vm.OpenAsync("main");
|
||||||
|
|
||||||
|
Assert.True(hasConflicts);
|
||||||
|
var file = Assert.Single(vm.Files);
|
||||||
|
Assert.Equal("README.md", file.Path);
|
||||||
|
Assert.False(vm.CanContinue); // nothing resolved yet
|
||||||
|
|
||||||
|
file.Hunks[0].AcceptIncomingCommand.Execute(null);
|
||||||
|
Assert.True(vm.CanContinue); // every hunk resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Continue_WritesComposedResolution_AndClosesOnMerged()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||||
|
var closed = false;
|
||||||
|
vm.CloseRequested = () => closed = true;
|
||||||
|
|
||||||
|
await vm.OpenAsync("main");
|
||||||
|
vm.Files[0].Hunks[0].AcceptCurrentCommand.Execute(null); // resolution = "ours\n"
|
||||||
|
await vm.ContinueCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal("README.md", worker.WrittenPath);
|
||||||
|
Assert.Equal("ours\n", worker.WrittenContent);
|
||||||
|
Assert.True(worker.Continued);
|
||||||
|
Assert.True(closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Continue_StaysOpenAndReportsError_WhenStillConflicted()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker { ContinueStatus = "conflict" };
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||||
|
var closed = false;
|
||||||
|
vm.CloseRequested = () => closed = true;
|
||||||
|
|
||||||
|
await vm.OpenAsync("main");
|
||||||
|
vm.Files[0].Hunks[0].AcceptBothCommand.Execute(null);
|
||||||
|
await vm.ContinueCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.False(closed);
|
||||||
|
Assert.NotNull(vm.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Abort_CallsWorkerAndCloses()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||||
|
var closed = false;
|
||||||
|
vm.CloseRequested = () => closed = true;
|
||||||
|
|
||||||
|
await vm.AbortCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.True(worker.Aborted);
|
||||||
|
Assert.True(closed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail** (VM not defined).
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"`
|
||||||
|
Expected: compile error / FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the ViewModel.**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
|
||||||
|
public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IWorkerClient _worker;
|
||||||
|
private readonly string _taskId;
|
||||||
|
|
||||||
|
public ObservableCollection<ConflictFile> Files { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
[ObservableProperty] private string? _error;
|
||||||
|
[ObservableProperty] private bool _canContinue;
|
||||||
|
|
||||||
|
public string TaskId => _taskId;
|
||||||
|
public Action? CloseRequested { get; set; }
|
||||||
|
|
||||||
|
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
|
||||||
|
{
|
||||||
|
_worker = worker;
|
||||||
|
_taskId = taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
|
||||||
|
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
|
||||||
|
public async Task<bool> OpenAsync(string targetBranch)
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
Error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
|
||||||
|
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
|
||||||
|
Error = start.ErrorMessage;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
|
||||||
|
Files.Clear();
|
||||||
|
foreach (var f in conflicts.Files)
|
||||||
|
{
|
||||||
|
var hunks = f.Hunks.Select(h =>
|
||||||
|
{
|
||||||
|
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
|
||||||
|
hk.PropertyChanged += OnHunkChanged;
|
||||||
|
return hk;
|
||||||
|
}).ToList();
|
||||||
|
Files.Add(new ConflictFile(f.Path, hunks));
|
||||||
|
}
|
||||||
|
RecomputeCanContinue();
|
||||||
|
return Files.Count > 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Error = ex.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
|
||||||
|
RecomputeCanContinue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecomputeCanContinue()
|
||||||
|
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ContinueAsync()
|
||||||
|
{
|
||||||
|
if (!CanContinue) return;
|
||||||
|
IsBusy = true;
|
||||||
|
Error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var file in Files)
|
||||||
|
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
|
||||||
|
|
||||||
|
var result = await _worker.ContinueMergeAsync(_taskId);
|
||||||
|
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
||||||
|
CloseRequested?.Invoke();
|
||||||
|
else
|
||||||
|
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Error = ex.Message;
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AbortAsync()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try { await _worker.AbortMergeAsync(_taskId); }
|
||||||
|
catch (Exception ex) { Error = ex.Message; }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
CloseRequested?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass.**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"`
|
||||||
|
Expected: PASS (4 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs
|
||||||
|
git commit -m "feat(ui): add inline conflict resolver view-model"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: ConflictResolverView + localization
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml`
|
||||||
|
- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/en.json`
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/de.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add localization keys** to `en.json` as a new top-level section (sibling of `"planning"`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"conflictResolver": {
|
||||||
|
"windowTitle": "Resolve merge conflicts",
|
||||||
|
"modalTitle": "RESOLVE CONFLICTS",
|
||||||
|
"loading": "Loading conflicts…",
|
||||||
|
"current": "Current (ours)",
|
||||||
|
"incoming": "Incoming (theirs)",
|
||||||
|
"mergedResult": "Merged result",
|
||||||
|
"acceptCurrent": "Accept Current",
|
||||||
|
"acceptIncoming": "Accept Incoming",
|
||||||
|
"acceptBoth": "Accept Both",
|
||||||
|
"editManually": "Edit manually",
|
||||||
|
"continue": "Resolve & continue",
|
||||||
|
"abort": "Abort merge"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the SAME keys to `de.json`** (German values, identical key set — parity enforced by Localization.Tests):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"conflictResolver": {
|
||||||
|
"windowTitle": "Merge-Konflikte lösen",
|
||||||
|
"modalTitle": "KONFLIKTE LÖSEN",
|
||||||
|
"loading": "Konflikte werden geladen…",
|
||||||
|
"current": "Aktuell (unsere)",
|
||||||
|
"incoming": "Eingehend (ihre)",
|
||||||
|
"mergedResult": "Zusammengeführtes Ergebnis",
|
||||||
|
"acceptCurrent": "Aktuelle übernehmen",
|
||||||
|
"acceptIncoming": "Eingehende übernehmen",
|
||||||
|
"acceptBoth": "Beide übernehmen",
|
||||||
|
"editManually": "Manuell bearbeiten",
|
||||||
|
"continue": "Lösen & fortfahren",
|
||||||
|
"abort": "Merge abbrechen"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the View** (`ConflictResolverView.axaml`). A `Window` using `ModalShell`, mirroring `ConflictResolutionView.axaml`. Two stacked read-only boxes (ours/theirs), a button row, and a two-way merged-result box per hunk.
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
|
||||||
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
|
x:DataType="vm:ConflictResolverViewModel"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
||||||
|
Title="{loc:Tr conflictResolver.windowTitle}"
|
||||||
|
Width="760" Height="640" MinWidth="560" MinHeight="420"
|
||||||
|
CanResize="True"
|
||||||
|
WindowDecorations="BorderOnly"
|
||||||
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
|
ExtendClientAreaTitleBarHeightHint="-1"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource SurfaceBrush}">
|
||||||
|
|
||||||
|
<Window.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
||||||
|
</Window.KeyBindings>
|
||||||
|
|
||||||
|
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
||||||
|
<ctl:ModalShell.Footer>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8"
|
||||||
|
HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
|
||||||
|
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ctl:ModalShell.Footer>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*" Margin="16,12">
|
||||||
|
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
|
||||||
|
Text="{loc:Tr conflictResolver.loading}"
|
||||||
|
IsVisible="{Binding IsBusy}"/>
|
||||||
|
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
||||||
|
Text="{Binding Error}" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1">
|
||||||
|
<ItemsControl ItemsSource="{Binding Files}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ConflictFile">
|
||||||
|
<StackPanel Spacing="8" Margin="0,0,0,16">
|
||||||
|
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Hunks}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ConflictHunk">
|
||||||
|
<Border BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="10" Margin="0,0,0,8">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
|
||||||
|
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
|
||||||
|
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"/>
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
|
||||||
|
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
|
||||||
|
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
|
||||||
|
Command="{Binding AcceptCurrentCommand}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
|
||||||
|
Command="{Binding AcceptIncomingCommand}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
|
||||||
|
Command="{Binding AcceptBothCommand}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
|
||||||
|
Command="{Binding EditManuallyCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
|
||||||
|
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
|
||||||
|
TextWrapping="NoWrap" AcceptsReturn="True" MinHeight="80" MaxHeight="200"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</ctl:ModalShell>
|
||||||
|
</Window>
|
||||||
|
```
|
||||||
|
**Note for the implementer:** if `MonoFont` / `path-mono` / `heading` / `meta` / `btn` resource keys or style classes don't resolve at build, drop the `FontFamily` attribute and unknown `Classes` (keep `btn`) — match whatever the existing `ConflictResolutionView.axaml` and app styles actually expose. Verify against `src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml` and the app's style resources before finalizing.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create the code-behind** (`ConflictResolverView.axaml.cs`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Conflicts;
|
||||||
|
|
||||||
|
public partial class ConflictResolverView : Window
|
||||||
|
{
|
||||||
|
public ConflictResolverView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDataContextChanged(System.EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDataContextChanged(e);
|
||||||
|
if (DataContext is ConflictResolverViewModel vm)
|
||||||
|
vm.CloseRequested = Close;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build the App + run Localization tests.**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release && dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||||
|
Expected: Build succeeded; localization parity tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
|
||||||
|
git commit -m "feat(ui): add inline conflict resolver view and localization"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Wire factory + dialog seam for the integrator
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.App/Program.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
|
||||||
|
|
||||||
|
These are additive seams only. The integrator connects Layer A/B's `RequestConflictResolution(taskId, target)` callback to `IslandsShellViewModel.RequestConflictResolutionAsync`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register the factory in `Program.cs`** (in the ViewModels region, near the other `Func<>` factories). Only the `Func<>` factory is needed — the VM is never resolved directly:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
|
||||||
|
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
|
||||||
|
sp.GetRequiredService<WorkerClient>(), taskId));
|
||||||
|
```
|
||||||
|
Then, after `IslandsShellViewModel` is registered, set the factory on it once resolved. Replace the existing `sc.AddSingleton<IslandsShellViewModel>();` registration with a factory that injects the conflict-resolver factory:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
||||||
|
{
|
||||||
|
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
||||||
|
shell.ConflictResolverFactory =
|
||||||
|
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
|
||||||
|
return shell;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
(`ActivatorUtilities.CreateInstance` resolves the existing big constructor + its `Func<>` deps exactly as the default registration did.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the additive seam to `IslandsShellViewModel`** (near the other `Show*` delegate properties):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
|
||||||
|
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
|
||||||
|
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
|
||||||
|
public Func<ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel, Task>? ShowConflictResolver { get; set; }
|
||||||
|
|
||||||
|
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
|
||||||
|
{
|
||||||
|
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
|
||||||
|
var vm = ConflictResolverFactory(taskId);
|
||||||
|
var hasConflicts = await vm.OpenAsync(targetBranch);
|
||||||
|
if (hasConflicts)
|
||||||
|
await ShowConflictResolver(vm);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Add `using ClaudeDo.Ui.ViewModels.Conflicts;` or use fully-qualified names as above.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire the dialog opener in `MainWindow.axaml.cs`** inside `OnDataContextChanged`, alongside the other `vm.Show*` assignments:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
vm.ShowConflictResolver = async (resolverVm) =>
|
||||||
|
{
|
||||||
|
var dlg = new ClaudeDo.Ui.Views.Conflicts.ConflictResolverView { DataContext = resolverVm };
|
||||||
|
await dlg.ShowDialog(this);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the App to verify compilation.**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
|
||||||
|
git commit -m "feat(ui): expose conflict-resolver factory and dialog seam for integrator"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Full verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build both head projects.**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
```
|
||||||
|
Expected: both Build succeeded, 0 errors/warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the full relevant test suites.**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Flag visual verification.** The resolver dialog cannot be opened end-to-end until the integrator wires Layer A/B's `RequestConflictResolution(taskId, target)` → `IslandsShellViewModel.RequestConflictResolutionAsync`. Report this as a visual-verification gap for the user/integrator: open a real conflicting merge, confirm hunks render, Accept buttons populate the merged box, Resolve & continue closes on success, Abort restores the tree.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Leave the branch for the orchestrator.** Do NOT push, do NOT merge to main.
|
||||||
@@ -0,0 +1,837 @@
|
|||||||
|
# Layer B — Multi-Worktree Merge Cockpit 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:** Turn the worktrees-overview modal into a batch-merge cockpit (multi-select N worktrees → one target branch → "Merge all" with skip-and-continue conflict collection), and migrate `WorktreeModalView`'s bespoke inline diff onto the canonical `DiffLinesView`.
|
||||||
|
|
||||||
|
**Architecture:** The cockpit VM keeps depending on the concrete `WorkerClient` (the overview/cleanup/state methods live only on `WorkerClient`, not `IWorkerClient`). The batch loop is extracted into a delegate-driven method `MergeSelectedAsync(Func<...> mergeFn)` so it is unit-testable with a fake merge function and a never-connected `WorkerClient`. Clean merges (`Status=="merged"`) update the row; conflicts (`Status=="conflict"`, which `MergeTaskAsync` already auto-aborts) are collected into a `ConflictRows` list whose rows expose a `Resolve` button wired to an inert `RequestConflictResolution(taskId, targetBranch)` seam. The diff migration replaces the right-pane `ItemsControl` in `WorktreeModalView` with `DiffLinesView`, feeding it `DiffLineViewModel`s produced by `UnifiedDiffParser`, and deletes the now-dead `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind`.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, xUnit. Build UI with `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`; run `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`.
|
||||||
|
|
||||||
|
**Frozen contracts reused (do NOT modify):**
|
||||||
|
- `WorkerClient.MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) -> Task<MergeResultDto>`
|
||||||
|
- `WorkerClient.GetMergeTargetsAsync(string taskId) -> Task<MergeTargetsDto?>` (`MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches)`)
|
||||||
|
- `MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)` — `Status` is `"merged" | "conflict" | "blocked" | <other>`
|
||||||
|
- `WorkerClient.GetWorktreesOverviewAsync`, `CleanupFinishedWorktreesAsync`, `SetWorktreeStateAsync`, `ForceRemoveWorktreeAsync`
|
||||||
|
- `GitService.GetFileDiffAsync(worktreePath, baseCommit?, relativePath)` returns a `git diff` blob including the `diff --git` header (so `UnifiedDiffParser.Parse` handles it)
|
||||||
|
- `DiffLinesView` (`Lines` styled property, `IEnumerable?`), `DiffLineViewModel`, `DiffFileViewModel`, `UnifiedDiffParser.Parse` / `.Flatten`
|
||||||
|
|
||||||
|
**Do NOT touch:** any worker-side files (`WorkerHub`, `TaskMergeService`, `GitService`), `IWorkerClient.cs` / `WorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, and do not create any `ConflictResolver` UI or reference any `ConflictResolver` type.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs` — **modify.** Add `BatchMergeOutcome` enum; add `IsChecked`/`MergeOutcome` (+ derived) to the row VM; add `MergeTargets`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `ConflictRows`, the `RequestConflictResolution` seam, `MergeSelectedAsync`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, target loading, and per-row check subscription. Keep all existing context-menu commands/wiring intact.
|
||||||
|
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml` — **modify.** Add a per-row checkbox + outcome badge, a target `ComboBox` + "Merge all" button + progress text in the toolbar, and a "Needs resolution" panel listing `ConflictRows` with `Resolve` buttons.
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs` — **modify.** Replace `SelectedFileDiffLines` element type with `DiffLineViewModel` produced via `UnifiedDiffParser`; delete `WorktreeDiffLineKind` and `WorktreeDiffLineViewModel`.
|
||||||
|
- `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml` — **modify.** Replace the right-pane `ItemsControl` with `ctl:DiffLinesView`; drop the `DiffLineKindToBrushConverter` resource.
|
||||||
|
- `src/ClaudeDo.Localization/locales/en.json` + `de.json` — **modify.** Add new `modals.worktreesOverview.*` and `vm.worktreesOverview.*` keys (keep parity).
|
||||||
|
- `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs` — **create.** Unit tests for `MergeSelectedAsync` skip-and-continue, conflict collection, progress, selection gating, and the resolve seam.
|
||||||
|
|
||||||
|
No `IWorkerClient` change → no test-fake updates needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Row-level batch state (outcome enum + row VM fields)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class WorktreesOverviewBatchMergeTests
|
||||||
|
{
|
||||||
|
private static WorktreeOverviewRowViewModel ActiveRow(string id) => new()
|
||||||
|
{
|
||||||
|
TaskId = id,
|
||||||
|
TaskTitle = $"Task {id}",
|
||||||
|
TaskStatus = TaskStatus.WaitingForReview,
|
||||||
|
State = WorktreeState.Active,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Row_outcome_helpers_reflect_state()
|
||||||
|
{
|
||||||
|
var row = ActiveRow("a");
|
||||||
|
Assert.Equal(BatchMergeOutcome.None, row.MergeOutcome);
|
||||||
|
Assert.False(row.IsConflict);
|
||||||
|
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||||
|
Assert.True(row.IsConflict);
|
||||||
|
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||||||
|
Assert.False(row.IsConflict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||||
|
Expected: FAIL — `BatchMergeOutcome` and `MergeOutcome`/`IsConflict` do not exist (compile error).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the enum and row fields**
|
||||||
|
|
||||||
|
In `WorktreesOverviewModalViewModel.cs`, add the enum just above `WorktreeOverviewRowViewModel`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Failed }
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside `WorktreeOverviewRowViewModel`, add after the existing `_isSelected` field:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty] private bool _isChecked;
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsConflict))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasOutcome))]
|
||||||
|
private BatchMergeOutcome _mergeOutcome;
|
||||||
|
|
||||||
|
public bool IsConflict => MergeOutcome == BatchMergeOutcome.Conflict;
|
||||||
|
public bool HasOutcome => MergeOutcome != BatchMergeOutcome.None;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||||
|
Expected: PASS (1 test).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
||||||
|
git commit -m "feat(ui): add batch-merge row state to worktrees cockpit VM"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Batch orchestration (`MergeSelectedAsync` skip-and-continue)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append to `WorktreesOverviewBatchMergeTests.cs`. The helper builds a VM with a never-connected `WorkerClient` (the loop never touches it) and seeds `Rows` directly:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static WorktreesOverviewModalViewModel NewVm() =>
|
||||||
|
new(new ClaudeDo.Ui.Services.WorkerClient("http://127.0.0.1:1/hub"), () => null!);
|
||||||
|
|
||||||
|
private static MergeResultDto Merged() => new("merged", System.Array.Empty<string>(), null);
|
||||||
|
private static MergeResultDto Conflict() => new("conflict", new[] { "f.cs" }, null);
|
||||||
|
private static MergeResultDto Blocked() => new("blocked", System.Array.Empty<string>(), "blocked");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task MergeSelected_only_processes_checked_active_rows()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a"); a.IsChecked = true;
|
||||||
|
var b = ActiveRow("b"); b.IsChecked = false; // unchecked -> skipped
|
||||||
|
var c = ActiveRow("c"); c.IsChecked = true; c.State = WorktreeState.Merged; // not active -> skipped
|
||||||
|
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
|
||||||
|
vm.SelectedTarget = "main";
|
||||||
|
|
||||||
|
var seen = new System.Collections.Generic.List<string>();
|
||||||
|
await vm.MergeSelectedAsync((id, target, remove, msg) =>
|
||||||
|
{
|
||||||
|
seen.Add(id);
|
||||||
|
Assert.Equal("main", target);
|
||||||
|
Assert.False(remove); // removeWorktree must be false
|
||||||
|
return System.Threading.Tasks.Task.FromResult(Merged());
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "a" }, seen);
|
||||||
|
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
|
||||||
|
Assert.False(a.IsChecked); // cleared after merge
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task MergeSelected_continues_past_conflict_and_collects_it()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a"); a.IsChecked = true;
|
||||||
|
var b = ActiveRow("b"); b.IsChecked = true;
|
||||||
|
var c = ActiveRow("c"); c.IsChecked = true;
|
||||||
|
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
|
||||||
|
vm.SelectedTarget = "main";
|
||||||
|
|
||||||
|
await vm.MergeSelectedAsync((id, target, remove, msg) =>
|
||||||
|
System.Threading.Tasks.Task.FromResult(id == "b" ? Conflict() : Merged()));
|
||||||
|
|
||||||
|
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
|
||||||
|
Assert.Equal(BatchMergeOutcome.Conflict, b.MergeOutcome);
|
||||||
|
Assert.Equal(BatchMergeOutcome.Merged, c.MergeOutcome); // continued past the conflict
|
||||||
|
Assert.Contains(b, vm.ConflictRows);
|
||||||
|
Assert.Single(vm.ConflictRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task MergeSelected_maps_blocked_and_exception_to_failure_outcomes()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a"); a.IsChecked = true;
|
||||||
|
var b = ActiveRow("b"); b.IsChecked = true;
|
||||||
|
vm.Rows.Add(a); vm.Rows.Add(b);
|
||||||
|
vm.SelectedTarget = "main";
|
||||||
|
|
||||||
|
await vm.MergeSelectedAsync((id, target, remove, msg) => id == "a"
|
||||||
|
? System.Threading.Tasks.Task.FromResult(Blocked())
|
||||||
|
: throw new System.InvalidOperationException("boom"));
|
||||||
|
|
||||||
|
Assert.Equal(BatchMergeOutcome.Blocked, a.MergeOutcome);
|
||||||
|
Assert.Equal(BatchMergeOutcome.Failed, b.MergeOutcome);
|
||||||
|
Assert.Empty(vm.ConflictRows);
|
||||||
|
Assert.False(vm.IsMerging);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task MergeSelected_noop_when_no_target()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a"); a.IsChecked = true;
|
||||||
|
vm.Rows.Add(a);
|
||||||
|
vm.SelectedTarget = null;
|
||||||
|
|
||||||
|
var called = false;
|
||||||
|
await vm.MergeSelectedAsync((id, t, r, m) => { called = true; return System.Threading.Tasks.Task.FromResult(Merged()); });
|
||||||
|
|
||||||
|
Assert.False(called);
|
||||||
|
Assert.Equal(BatchMergeOutcome.None, a.MergeOutcome);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||||
|
Expected: FAIL — `MergeSelectedAsync`, `ConflictRows`, `IsMerging`, `SelectedTarget` do not exist (compile error).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the orchestration + cockpit fields**
|
||||||
|
|
||||||
|
In `WorktreesOverviewModalViewModel.cs`, add these `using`s if missing: `using ClaudeDo.Ui.Services;` (already present). Add fields/properties to `WorktreesOverviewModalViewModel` (after the existing `_selectedRow` field):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget;
|
||||||
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount;
|
||||||
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging;
|
||||||
|
[ObservableProperty] private string? _batchProgress;
|
||||||
|
|
||||||
|
public ObservableCollection<string> MergeTargets { get; } = new();
|
||||||
|
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
|
||||||
|
|
||||||
|
/// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
|
||||||
|
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a helper to enumerate rows regardless of grouped/flat mode, plus the orchestration method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
|
||||||
|
IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;
|
||||||
|
|
||||||
|
public async Task MergeSelectedAsync(
|
||||||
|
Func<string, string, bool, string, Task<MergeResultDto>> mergeFn,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var target = SelectedTarget;
|
||||||
|
if (string.IsNullOrWhiteSpace(target)) return;
|
||||||
|
|
||||||
|
var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList();
|
||||||
|
if (selected.Count == 0) return;
|
||||||
|
|
||||||
|
IsMerging = true;
|
||||||
|
ConflictRows.Clear();
|
||||||
|
var done = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var row in selected)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Merging;
|
||||||
|
BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count);
|
||||||
|
|
||||||
|
MergeResultDto result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await mergeFn(row.TaskId, target!, false,
|
||||||
|
Loc.T("vm.merge.commitMessage", row.TaskTitle));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (result.Status)
|
||||||
|
{
|
||||||
|
case "merged":
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||||||
|
row.State = WorktreeState.Merged;
|
||||||
|
row.IsChecked = false;
|
||||||
|
break;
|
||||||
|
case "conflict":
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||||
|
ConflictRows.Add(row);
|
||||||
|
break;
|
||||||
|
case "blocked":
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Blocked;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
|
||||||
|
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsMerging = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: `Loc.T` keys are added in Task 5; they resolve to the key name (harmless) until then, so tests pass now.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||||
|
Expected: PASS (5 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
||||||
|
git commit -m "feat(ui): add skip-and-continue batch merge orchestration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Selection tracking, target loading, commands + resolve seam
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append to `WorktreesOverviewBatchMergeTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void SelectedCount_tracks_checked_active_rows()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a");
|
||||||
|
var b = ActiveRow("b");
|
||||||
|
var merged = ActiveRow("c"); merged.State = WorktreeState.Merged;
|
||||||
|
vm.AddRowForTest(a); vm.AddRowForTest(b); vm.AddRowForTest(merged);
|
||||||
|
|
||||||
|
Assert.Equal(0, vm.SelectedCount);
|
||||||
|
a.IsChecked = true;
|
||||||
|
Assert.Equal(1, vm.SelectedCount);
|
||||||
|
b.IsChecked = true;
|
||||||
|
merged.IsChecked = true; // not active -> not counted
|
||||||
|
Assert.Equal(2, vm.SelectedCount);
|
||||||
|
a.IsChecked = false;
|
||||||
|
Assert.Equal(1, vm.SelectedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveConflict_invokes_seam_with_task_and_target()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
vm.SelectedTarget = "release";
|
||||||
|
var row = ActiveRow("x"); row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||||
|
|
||||||
|
(string Task, string Target)? captured = null;
|
||||||
|
vm.RequestConflictResolution = (taskId, target) => { captured = (taskId, target); return System.Threading.Tasks.Task.CompletedTask; };
|
||||||
|
|
||||||
|
vm.ResolveConflictCommand.Execute(row);
|
||||||
|
|
||||||
|
Assert.Equal(("x", "release"), captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MergeAll_canExecute_requires_target_selection_and_idle()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a");
|
||||||
|
vm.AddRowForTest(a);
|
||||||
|
|
||||||
|
Assert.False(vm.MergeAllCommand.CanExecute(null)); // no selection, no target
|
||||||
|
a.IsChecked = true;
|
||||||
|
Assert.False(vm.MergeAllCommand.CanExecute(null)); // still no target
|
||||||
|
vm.SelectedTarget = "main";
|
||||||
|
Assert.True(vm.MergeAllCommand.CanExecute(null));
|
||||||
|
vm.IsMerging = true;
|
||||||
|
Assert.False(vm.MergeAllCommand.CanExecute(null)); // busy
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||||
|
Expected: FAIL — `AddRowForTest`, `ResolveConflictCommand`, `MergeAllCommand` do not exist (compile error).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement subscription, commands, target loading**
|
||||||
|
|
||||||
|
In `WorktreesOverviewModalViewModel.cs`:
|
||||||
|
|
||||||
|
(a) Add a row-hook that recomputes `SelectedCount` when a row's `IsChecked` changes, and a test seam to add a hooked row. Add these methods to the class:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void HookRow(WorktreeOverviewRowViewModel row)
|
||||||
|
{
|
||||||
|
row.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked)
|
||||||
|
or nameof(WorktreeOverviewRowViewModel.State))
|
||||||
|
RecomputeSelected();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecomputeSelected() =>
|
||||||
|
SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive);
|
||||||
|
|
||||||
|
// Test seam: adds a row to the flat list with selection tracking wired up.
|
||||||
|
internal void AddRowForTest(WorktreeOverviewRowViewModel row)
|
||||||
|
{
|
||||||
|
HookRow(row);
|
||||||
|
Rows.Add(row);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) In `LoadAsync`, call `HookRow(row)` everywhere a row is added. Replace the two add sites:
|
||||||
|
|
||||||
|
In the grouped branch, change `foreach (var row in grp) group.Rows.Add(row);` to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
foreach (var row in grp) { HookRow(row); group.Rows.Add(row); }
|
||||||
|
```
|
||||||
|
|
||||||
|
In the flat branch, change `foreach (var row in ordered) Rows.Add(row);` to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
|
||||||
|
```
|
||||||
|
|
||||||
|
Also, at the start of `LoadAsync` after `IsBusy = true;`, reset batch UI state and (re)load merge targets at the end of the `try`:
|
||||||
|
|
||||||
|
After `Rows.Clear(); Groups.Clear();` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
ConflictRows.Clear();
|
||||||
|
SelectedCount = 0;
|
||||||
|
BatchProgress = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
At the very end of the `try` block (after the if/else that fills rows/groups) add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await LoadMergeTargetsAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
(c) Add target loading. The branch list is repo-level, so query it from the first active row:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task LoadMergeTargetsAsync()
|
||||||
|
{
|
||||||
|
var anchor = AllRows.FirstOrDefault(r => r.IsActive);
|
||||||
|
if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId);
|
||||||
|
MergeTargets.Clear();
|
||||||
|
if (targets is null) { SelectedTarget = null; return; }
|
||||||
|
foreach (var b in targets.LocalBranches) MergeTargets.Add(b);
|
||||||
|
SelectedTarget = MergeTargets.Contains(targets.DefaultBranch)
|
||||||
|
? targets.DefaultBranch
|
||||||
|
: MergeTargets.FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch { MergeTargets.Clear(); SelectedTarget = null; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(d) Add the commands:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
||||||
|
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleSelectAll()
|
||||||
|
{
|
||||||
|
var actives = AllRows.Where(r => r.IsActive).ToList();
|
||||||
|
var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked);
|
||||||
|
foreach (var r in actives) r.IsChecked = !allChecked;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||||
|
Expected: PASS (8 tests total in this file).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build the app project to confirm the VM compiles against generated commands**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
||||||
|
git commit -m "feat(ui): wire batch selection, target loading and resolve seam"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Cockpit view — checkboxes, target picker, Merge all, conflicts panel
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
|
||||||
|
|
||||||
|
This task is AXAML only (no logic) → no new unit test; flag for visual verification.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the batch toolbar controls**
|
||||||
|
|
||||||
|
In `WorktreesOverviewModalView.axaml`, replace the toolbar `StackPanel` (currently containing Refresh, Cleanup finished, StatusMessage) with one that adds select-all, the target picker, the Merge-all button and progress text. Replace the inner `<StackPanel Orientation="Horizontal" Spacing="8">...</StackPanel>` of the toolbar `Border` with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.refresh}" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.cleanupFinished}" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.selectAll}" Command="{Binding ToggleSelectAllCommand}"/>
|
||||||
|
<Border Width="1" Background="{DynamicResource LineBrush}" Margin="4,2"/>
|
||||||
|
<TextBlock Text="{loc:Tr modals.worktreesOverview.targetLabel}" VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<ComboBox MinWidth="160"
|
||||||
|
ItemsSource="{Binding MergeTargets}"
|
||||||
|
SelectedItem="{Binding SelectedTarget, Mode=TwoWay}"/>
|
||||||
|
<Button Classes="btn accent"
|
||||||
|
Content="{loc:Tr modals.worktreesOverview.mergeAll}"
|
||||||
|
Command="{Binding MergeAllCommand}"/>
|
||||||
|
<TextBlock Text="{Binding SelectedCount, StringFormat='{}{0} selected'}"
|
||||||
|
VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Text="{Binding BatchProgress}" VerticalAlignment="Center" Margin="8,0,0,0"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="8,0,0,0"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a checkbox + outcome badge to the row template**
|
||||||
|
|
||||||
|
In the `WorktreeRowTemplate` `DataTemplate`, change the row `Grid` to add a leading checkbox column and a trailing outcome column. Replace the `<Grid ColumnDefinitions="*,90,80,80">...</Grid>` (the whole grid, lines for Task/State/Diff/Age) with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
|
||||||
|
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||||
|
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
||||||
|
IsEnabled="{Binding IsActive}"
|
||||||
|
IsVisible="{Binding IsActive}"/>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
|
||||||
|
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
|
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
|
||||||
|
<TextBlock Classes="meta" Text="•"
|
||||||
|
IsVisible="{Binding !PathExistsOnDisk}"/>
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.phantom}" Foreground="{DynamicResource StatusErrorBrush}"
|
||||||
|
IsVisible="{Binding !PathExistsOnDisk}"
|
||||||
|
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
|
||||||
|
Text="{Binding MergeOutcome}"
|
||||||
|
IsVisible="{Binding HasOutcome}"/>
|
||||||
|
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
|
||||||
|
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
|
||||||
|
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update the column-header `Grid` (the one with `ColumnDefinitions="*,90,80,80"` near the ScrollViewer top) to match the new column layout:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
|
||||||
|
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
|
||||||
|
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
|
||||||
|
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
|
||||||
|
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
|
||||||
|
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
|
||||||
|
</Grid>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the "Needs resolution" panel**
|
||||||
|
|
||||||
|
Inside the content `ScrollViewer`'s root `StackPanel`, at the very top (before the column-header `Grid`), add a conflicts panel that only shows when there are conflicts:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Border IsVisible="{Binding ConflictRows.Count}"
|
||||||
|
Background="{DynamicResource ErrorTintBrush}"
|
||||||
|
BorderBrush="{DynamicResource StatusErrorBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="6" Padding="12,8" Margin="0,0,0,12">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.needsResolution}"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding ConflictRows}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
|
||||||
|
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||||||
|
Text="{Binding TaskTitle}"/>
|
||||||
|
<Button Grid.Column="1" Classes="btn"
|
||||||
|
Content="{loc:Tr modals.worktreesOverview.resolve}"
|
||||||
|
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
```
|
||||||
|
|
||||||
|
> `IsVisible="{Binding ConflictRows.Count}"` uses Avalonia's int→bool coercion (0 = false). If the build flags this, change to a value converter already present, but int→bool is supported.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the app to verify the AXAML compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded (compiled bindings resolve against the new VM members).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml
|
||||||
|
git commit -m "feat(ui): batch-merge cockpit view with checkboxes and conflicts panel"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Localization keys (en + de parity)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/en.json`
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/de.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the new keys to `en.json`**
|
||||||
|
|
||||||
|
Under `modals.worktreesOverview`, add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"columnOutcome": "RESULT",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"targetLabel": "Target",
|
||||||
|
"mergeAll": "Merge all",
|
||||||
|
"needsResolution": "NEEDS RESOLUTION",
|
||||||
|
"resolve": "Resolve"
|
||||||
|
```
|
||||||
|
|
||||||
|
Under `vm.worktreesOverview`, add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"batchProgress": "Merging {0}/{1}…",
|
||||||
|
"batchDone": "Merged {0}, {1} need resolution."
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the matching keys to `de.json`**
|
||||||
|
|
||||||
|
Under `modals.worktreesOverview`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"columnOutcome": "ERGEBNIS",
|
||||||
|
"selectAll": "Alle auswählen",
|
||||||
|
"targetLabel": "Ziel",
|
||||||
|
"mergeAll": "Alle mergen",
|
||||||
|
"needsResolution": "ZU LÖSEN",
|
||||||
|
"resolve": "Lösen"
|
||||||
|
```
|
||||||
|
|
||||||
|
Under `vm.worktreesOverview`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"batchProgress": "Merge {0}/{1}…",
|
||||||
|
"batchDone": "{0} gemergt, {1} zu lösen."
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the localization parity test**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||||
|
Expected: PASS (en/de key parity holds).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
|
||||||
|
git commit -m "feat(i18n): add batch-merge cockpit strings (en/de)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Migrate `WorktreeModalView` diff onto `DiffLinesView`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Switch the VM to the canonical diff model**
|
||||||
|
|
||||||
|
In `WorktreeModalViewModel.cs`:
|
||||||
|
|
||||||
|
(a) Delete the now-dead types at the top of the file:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context }
|
||||||
|
|
||||||
|
public sealed partial class WorktreeDiffLineViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public required string Text { get; init; }
|
||||||
|
public required WorktreeDiffLineKind Kind { get; init; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Change the collection declaration from:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public ObservableCollection<DiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||||
|
```
|
||||||
|
|
||||||
|
(c) Replace the body of `LoadFileDiffAsync` (the `foreach (var line in diff.Split('\n'))` block) so it parses via `UnifiedDiffParser`. The method becomes:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task LoadFileDiffAsync(WorktreeNodeViewModel? node)
|
||||||
|
{
|
||||||
|
SelectedFileDiffLines.Clear();
|
||||||
|
|
||||||
|
if (node is null || node.IsDirectory || string.IsNullOrEmpty(node.RelativePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
string diff;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
diff = await _git.GetFileDiffAsync(WorktreePath, BaseCommit, node.RelativePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
|
||||||
|
SelectedFileDiffLines.Add(line);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`DiffLineViewModel`, `DiffFileViewModel`, and `UnifiedDiffParser` are all in the same `ClaudeDo.Ui.ViewModels.Modals` namespace, so no new `using` is required.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build to confirm the VM compiles and nothing else referenced the deleted types**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded. (If a compile error names `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind` outside this file or the view, that reference must be migrated too — there should be none besides `WorktreeModalView.axaml`, handled next.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Swap the view's inline diff for `DiffLinesView`**
|
||||||
|
|
||||||
|
In `WorktreeModalView.axaml`:
|
||||||
|
|
||||||
|
(a) Remove the now-unused converter resource. Delete:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Window.Resources>
|
||||||
|
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
|
||||||
|
</Window.Resources>
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Replace the right-pane `ScrollViewer`'s `ItemsControl` (the `SelectableTextBlock` template bound to `SelectedFileDiffLines`) with the canonical control. Replace:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ItemsControl ItemsSource="{Binding SelectedFileDiffLines}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:WorktreeDiffLineViewModel">
|
||||||
|
<SelectableTextBlock Text="{Binding Text}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="{StaticResource FontSizeMono}"
|
||||||
|
Foreground="{Binding Kind, Converter={StaticResource DiffLineKindToBrush}}"
|
||||||
|
TextWrapping="NoWrap"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ctl:DiffLinesView Lines="{Binding SelectedFileDiffLines}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
(The `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` namespace is already declared at the top of this file.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the app to verify the AXAML compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml
|
||||||
|
git commit -m "refactor(ui): render worktree modal diff via canonical DiffLinesView"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Full build + test sweep
|
||||||
|
|
||||||
|
**Files:** none (verification only).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build the whole app**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the UI + localization test projects**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||||
|
Then: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||||
|
Expected: PASS (all green, including the 8 new batch-merge tests).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Flag visual-verification gaps**
|
||||||
|
|
||||||
|
The cockpit toolbar/checkbox/conflicts-panel layout and the migrated `WorktreeModalView` diff rendering are AXAML changes that cannot be verified headlessly. Report to the user that these need a visual pass (run the app, open the worktrees overview, select several worktrees, pick a target, "Merge all", and open a worktree diff).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage:** batch-merge cockpit (Tasks 1–4), skip-and-continue + conflict collection (Task 2), single target picker (Tasks 3–4), Resolve → `RequestConflictResolution(taskId, targetBranch)` seam left unwired (Tasks 3–4), `WorktreeModalView` diff migration to `DiffLinesView` (Task 6), no worker files touched, no `IWorkerClient` change, locales in parity (Task 5). ✔
|
||||||
|
- **No ConflictResolver reference:** the seam is a bare `Func<string,string,Task>?`; no Layer C type is named. ✔
|
||||||
|
- **Type consistency:** `BatchMergeOutcome`, `MergeOutcome`, `IsConflict`, `HasOutcome`, `MergeSelectedAsync`, `ConflictRows`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `RequestConflictResolution`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, `AddRowForTest`, `AllRows` are used consistently across tasks. ✔
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
# Terminal-style Review Controls 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:** Move review feedback into the Output (terminal) tab as a prompt-style input with `[Retry]`/`[Reset]` actions, and relocate Approve + all merge/worktree controls to a new **Git** tab.
|
||||||
|
|
||||||
|
**Architecture:** Pure UI-layer change in `ClaudeDo.Ui`. Add an `IsGitTab` computed flag to `DetailsIslandViewModel`, re-home existing XAML blocks across three tabs (Output · Git · Session) in `WorkConsole.axaml`, add a bottom-docked review footer to the Output tab, and intercept Enter in `WorkConsole.axaml.cs`. No worker-side or `IWorkerClient` changes; no ViewModel command renames.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, xUnit (ClaudeDo.Ui.Tests).
|
||||||
|
|
||||||
|
**Reference spec:** `docs/superpowers/specs/2026-06-05-terminal-review-design.md`
|
||||||
|
|
||||||
|
**Build/test note (from CLAUDE.md):** A running Worker locks `Debug` output — build UI in `-c Release`:
|
||||||
|
`dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
`dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — add `IsGitTab`, wire notifications.
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — add Git tab button; split tab bodies; add Output-tab review footer; update Session empty-state text.
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs` — Enter-to-Retry key handling.
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs` (create) — `IsGitTab` behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add `IsGitTab` tab flag to the ViewModel
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:139-147`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs` (create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs`. Mirror the
|
||||||
|
construction pattern from `DetailsIslandPrepModeTests.cs` (temp SQLite db,
|
||||||
|
`TestDbFactory`, `StubWorkerClient`, `NullServiceProvider`, `StubNotesApi`).
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class DetailsIslandTabsTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
public DetailsIslandTabsTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_tabs_test_{Guid.NewGuid():N}.db");
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { File.Delete(_dbPath); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClaudeDoDbContext NewContext()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite($"Data Source={_dbPath}")
|
||||||
|
.Options;
|
||||||
|
return new ClaudeDoDbContext(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||||
|
{
|
||||||
|
private readonly Func<ClaudeDoDbContext> _create;
|
||||||
|
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||||
|
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
|
||||||
|
{
|
||||||
|
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
|
||||||
|
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
|
||||||
|
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
|
||||||
|
public Task DeleteAsync(string id) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullServiceProvider : IServiceProvider
|
||||||
|
{
|
||||||
|
public object? GetService(Type serviceType) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// StubWorkerClient is abstract — use a concrete no-op subclass (same pattern as DetailsIslandPrepModeTests).
|
||||||
|
private sealed class DefaultStub : StubWorkerClient { }
|
||||||
|
|
||||||
|
private DetailsIslandViewModel NewVm()
|
||||||
|
{
|
||||||
|
var factory = new TestDbFactory(NewContext);
|
||||||
|
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new StubNotesApi());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectTab_git_sets_IsGitTab_and_clears_others()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
|
||||||
|
vm.SelectTabCommand.Execute("git");
|
||||||
|
|
||||||
|
Assert.True(vm.IsGitTab);
|
||||||
|
Assert.False(vm.IsOutputTab);
|
||||||
|
Assert.False(vm.IsSessionTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Default_tab_is_output_not_git()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
|
||||||
|
Assert.True(vm.IsOutputTab);
|
||||||
|
Assert.False(vm.IsGitTab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DetailsIslandTabsTests`
|
||||||
|
Expected: FAIL — compile error, `DetailsIslandViewModel` has no `IsGitTab`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `IsGitTab` to the ViewModel**
|
||||||
|
|
||||||
|
In `DetailsIslandViewModel.cs`, find the `SelectedTab` property notifications and the
|
||||||
|
tab getters (around lines 139-147). Add the `IsGitTab` notification and getter:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsSessionTab))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsGitTab))]
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public bool IsOutputTab => SelectedTab == "output";
|
||||||
|
public bool IsGitTab => SelectedTab == "git";
|
||||||
|
public bool IsSessionTab => SelectedTab == "session";
|
||||||
|
```
|
||||||
|
|
||||||
|
(Leave `SelectTab` unchanged — it already accepts any string and defaults to `"output"`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DetailsIslandTabsTests`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs
|
||||||
|
git commit -m "feat(ui): add IsGitTab flag to work console view model"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add the Git tab button and move the merge/worktree block onto it
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:124-135` (tab strip)
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:164-273` (tab body)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the Git tab button**
|
||||||
|
|
||||||
|
In the tab strip `StackPanel` (lines 124-135), insert a Git button between the Output
|
||||||
|
and Session buttons:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<Button Classes="tab-btn"
|
||||||
|
Classes.active="{Binding IsOutputTab}"
|
||||||
|
Content="Output"
|
||||||
|
Command="{Binding SelectTabCommand}"
|
||||||
|
CommandParameter="output" />
|
||||||
|
<Button Classes="tab-btn"
|
||||||
|
Classes.active="{Binding IsGitTab}"
|
||||||
|
Content="Git"
|
||||||
|
Command="{Binding SelectTabCommand}"
|
||||||
|
CommandParameter="git" />
|
||||||
|
<Button Classes="tab-btn"
|
||||||
|
Classes.active="{Binding IsSessionTab}"
|
||||||
|
Content="Session"
|
||||||
|
Command="{Binding SelectTabCommand}"
|
||||||
|
CommandParameter="session" />
|
||||||
|
</StackPanel>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Move the "Merge & worktree" block to a new Git-tab ScrollViewer**
|
||||||
|
|
||||||
|
In the tab body `Grid` (starts line 139), the body currently holds the Output
|
||||||
|
`ScrollViewer` (`IsVisible="{Binding IsOutputTab}"`, lines 142-162) and the Session
|
||||||
|
`ScrollViewer` (`IsVisible="{Binding IsSessionTab}"`, lines 165-273).
|
||||||
|
|
||||||
|
Cut the **entire "Merge & worktree management" `StackPanel`** — the block currently at
|
||||||
|
lines 195-241, beginning with the comment `<!-- Merge & worktree management -->` and the
|
||||||
|
`<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">` and ending at its
|
||||||
|
matching `</StackPanel>` after the `MergeAllError` `TextBlock` (line 241).
|
||||||
|
|
||||||
|
Add a new Git-tab `ScrollViewer` between the Output and Session `ScrollViewer`s, and
|
||||||
|
paste the cut block inside it:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- Git: merge target, approve, diff, worktree -->
|
||||||
|
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
|
||||||
|
<StackPanel Spacing="14">
|
||||||
|
|
||||||
|
<!-- Approve (review-gated) -->
|
||||||
|
<StackPanel Spacing="8" IsVisible="{Binding IsWaitingForReview}">
|
||||||
|
<TextBlock Classes="section-label" Text="REVIEW" />
|
||||||
|
<Button Classes="btn accent" Content="Approve"
|
||||||
|
Command="{Binding ApproveReviewCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Merge & worktree management (moved from Session tab) -->
|
||||||
|
<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">
|
||||||
|
<TextBlock Classes="section-label" Text="MERGE & WORKTREE" />
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="field-label" Text="Merge target" />
|
||||||
|
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
||||||
|
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
||||||
|
HorizontalAlignment="Stretch" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource MossBrush}"
|
||||||
|
IsVisible="{Binding MergeIsClean}" />
|
||||||
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
|
IsVisible="{Binding MergeIsConflict}" />
|
||||||
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
IsVisible="{Binding ShowMergePreviewMuted}" />
|
||||||
|
</StackPanel>
|
||||||
|
<WrapPanel Orientation="Horizontal">
|
||||||
|
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
|
||||||
|
Command="{Binding OpenDiffCommand}" />
|
||||||
|
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
|
||||||
|
Command="{Binding MergeCommand}"
|
||||||
|
IsVisible="{Binding ShowSingleMerge}" />
|
||||||
|
<Button Classes="btn" Margin="0,0,8,8"
|
||||||
|
Command="{Binding OpenWorktreeCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||||
|
<TextBlock Text="Worktree" />
|
||||||
|
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
|
||||||
|
Command="{Binding ReviewCombinedDiffCommand}" />
|
||||||
|
<Button Classes="btn accent" Content="Merge All Subtasks" Margin="0,0,0,8"
|
||||||
|
Command="{Binding MergeAllCommand}"
|
||||||
|
IsEnabled="{Binding CanMergeAll}"
|
||||||
|
ToolTip.Tip="{Binding MergeAllDisabledReason}" />
|
||||||
|
</WrapPanel>
|
||||||
|
<TextBlock Text="{Binding MergeAllError}"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding MergeAllError,
|
||||||
|
Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove the old review block from the Session tab**
|
||||||
|
|
||||||
|
In the Session `ScrollViewer` (`IsVisible="{Binding IsSessionTab}"`), delete the
|
||||||
|
**"Review controls" `StackPanel`** currently at lines 168-193 (the
|
||||||
|
`<!-- Review controls -->` comment, the `<StackPanel Spacing="8" IsVisible="{Binding IsWaitingForReview}">`,
|
||||||
|
the REVIEW label, Feedback label, the `ReviewFeedback` TextBox, and the four buttons).
|
||||||
|
After this and Step 2, the Session tab's `StackPanel` should contain only the Child
|
||||||
|
outcomes block (lines 244-263) and the empty-state `TextBlock` (lines 266-270).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build and verify it compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
|
||||||
|
git commit -m "feat(ui): add Git tab and move merge/approve controls onto it"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add the prompt-style review footer to the Output tab
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` (Output-tab area + the `Grid` body)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Restructure the Output tab body to dock a footer below the log**
|
||||||
|
|
||||||
|
The body `Grid` (line 139) overlays all three tab `ScrollViewer`s. Replace the Output
|
||||||
|
`ScrollViewer` (lines 142-162) with a `DockPanel` that keeps the log filling and docks
|
||||||
|
the review footer at the bottom. Keep `Name="LogScroll"` on the `ScrollViewer` (the
|
||||||
|
code-behind references it). Use this exact markup:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- Output: log + review footer, both gated on IsOutputTab -->
|
||||||
|
<DockPanel IsVisible="{Binding IsOutputTab}" LastChildFill="True">
|
||||||
|
|
||||||
|
<!-- Review footer (terminal prompt) — only while awaiting review -->
|
||||||
|
<Border DockPanel.Dock="Bottom"
|
||||||
|
IsVisible="{Binding IsWaitingForReview}"
|
||||||
|
Background="{DynamicResource Surface2Brush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="0,1,0,0"
|
||||||
|
Padding="10,6">
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="8"
|
||||||
|
VerticalAlignment="Bottom" Margin="8,0,0,0">
|
||||||
|
<Button Classes="btn accent" Content="Retry"
|
||||||
|
Command="{Binding RejectReviewCommand}" />
|
||||||
|
<Button Classes="btn" Content="Reset"
|
||||||
|
Command="{Binding ParkReviewCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="❯"
|
||||||
|
FontFamily="{StaticResource MonoFont}"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
VerticalAlignment="Top" Margin="0,4,8,0" />
|
||||||
|
<TextBox Name="ReviewInput"
|
||||||
|
Text="{Binding ReviewFeedback, Mode=TwoWay}"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
MaxHeight="160"
|
||||||
|
PlaceholderText="Feedback for the next run…"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="0,2"
|
||||||
|
FontFamily="{StaticResource MonoFont}"
|
||||||
|
FontSize="{StaticResource FontSizeMono}" />
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ScrollViewer Name="LogScroll"
|
||||||
|
VerticalScrollBarVisibility="Visible"
|
||||||
|
AllowAutoHide="False"
|
||||||
|
Padding="12,8,12,4">
|
||||||
|
<ItemsControl ItemsSource="{Binding Log}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:LogLineViewModel">
|
||||||
|
<Grid ColumnDefinitions="60,*" Margin="0,1">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Classes="log-ts"
|
||||||
|
Text="{Binding TimestampFormatted}" />
|
||||||
|
<SelectableTextBlock Grid.Column="1"
|
||||||
|
Text="{Binding Text}" Tag="{Binding ClassName}"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</DockPanel>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build and verify it compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
|
||||||
|
git commit -m "feat(ui): add terminal review footer with Retry/Reset to Output tab"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Enter-to-Retry key handling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the KeyDown handler**
|
||||||
|
|
||||||
|
In `WorkConsole.axaml.cs`, add `using Avalonia.Input;` at the top. Add a handler that
|
||||||
|
runs `RejectReviewCommand` on Enter (without Shift) and lets Shift+Enter insert a
|
||||||
|
newline. Wire it from the `ReviewInput` TextBox. Full file:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
||||||
|
|
||||||
|
public partial class WorkConsole : UserControl
|
||||||
|
{
|
||||||
|
private INotifyCollectionChanged? _log;
|
||||||
|
|
||||||
|
public WorkConsole()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContextChanged += OnDataContextChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_log is not null)
|
||||||
|
_log.CollectionChanged -= OnLogChanged;
|
||||||
|
|
||||||
|
_log = (DataContext as DetailsIslandViewModel)?.Log;
|
||||||
|
|
||||||
|
if (_log is not null)
|
||||||
|
_log.CollectionChanged += OnLogChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Action != NotifyCollectionChangedAction.Add) return;
|
||||||
|
EventHandler? handler = null;
|
||||||
|
handler = (_, _) =>
|
||||||
|
{
|
||||||
|
LogScroll.LayoutUpdated -= handler;
|
||||||
|
LogScroll.ScrollToEnd();
|
||||||
|
};
|
||||||
|
LogScroll.LayoutUpdated += handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnReviewInputKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key != Key.Enter || e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (DataContext is DetailsIslandViewModel vm &&
|
||||||
|
vm.RejectReviewCommand.CanExecute(null))
|
||||||
|
{
|
||||||
|
vm.RejectReviewCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire the handler in XAML**
|
||||||
|
|
||||||
|
On the `ReviewInput` TextBox added in Task 3, add the event hookup attribute:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<TextBox Name="ReviewInput"
|
||||||
|
KeyDown="OnReviewInputKeyDown"
|
||||||
|
Text="{Binding ReviewFeedback, Mode=TwoWay}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build and verify it compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs
|
||||||
|
git commit -m "feat(ui): send Retry on Enter in the review prompt"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update the Session empty-state copy
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` (empty-state `TextBlock`, was line 266-270)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Reword the empty-state text**
|
||||||
|
|
||||||
|
The Session empty-state still says review/merge controls appear there. Replace its
|
||||||
|
`Text` so it reflects that those moved:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<TextBlock IsVisible="{Binding ShowSessionEmpty}"
|
||||||
|
Classes="meta"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="Nothing to manage yet — subtask outcomes appear here once the run finishes. Review in the Output tab, merge in the Git tab." />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build and verify it compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
|
||||||
|
git commit -m "docs(ui): reword Session empty-state for relocated review/merge controls"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Final verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the full UI test project**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||||
|
Expected: all tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Manual visual verification (cannot be auto-verified — flag to user)**
|
||||||
|
|
||||||
|
Launch the app with a task in `WaitingForReview` and confirm:
|
||||||
|
- Output tab shows the prompt footer (`❯` + input + `[Retry]` `[Reset]`) only while awaiting review; it is hidden otherwise.
|
||||||
|
- Typing + **Enter** sends Retry (requeues with feedback); **Shift+Enter** inserts a newline; **Enter on empty input** does nothing.
|
||||||
|
- `[Reset]` parks the task to Idle.
|
||||||
|
- Git tab shows **Approve** + merge target + Open Diff / Merge / Worktree / Review Combined Diff / Merge All Subtasks.
|
||||||
|
- Session tab shows only subtask outcomes / the reworded empty state.
|
||||||
|
- Tab switching highlights the active tab correctly (Output ↔ Git ↔ Session).
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Plan: Per-task model override via MCP + cheapest-model prompt guidance
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-09-per-task-model-override-design.md`
|
||||||
|
|
||||||
|
TDD, one focused commit per task. Build with `-c Release` per project; run
|
||||||
|
`ClaudeDo.Worker.Tests` (and `Data.Tests` if touched).
|
||||||
|
|
||||||
|
## Task 1 — ModelRegistry: cost ordering + alias validation
|
||||||
|
|
||||||
|
- Add `ByCostAscending = ["haiku","sonnet","opus"]`.
|
||||||
|
- Add `string? NormalizeAlias(string? model)`: trim; null/blank → null;
|
||||||
|
case-insensitive match against `Aliases` → canonical lowercase; else throw
|
||||||
|
`ArgumentException($"Unknown model '{model}'. Allowed: {join(Aliases)}.")`.
|
||||||
|
- Tests (Data.Tests): "sonnet"/"OPUS"/" haiku " → normalized; ""/null/" " →
|
||||||
|
null; "gpt4" → throws.
|
||||||
|
|
||||||
|
## Task 2 — CreateChildAsync accepts model
|
||||||
|
|
||||||
|
- `TaskRepository.CreateChildAsync`: add `string? model = null` (before the
|
||||||
|
trailing `CancellationToken ct = default`); set
|
||||||
|
`child.Model = ModelRegistry.NormalizeAlias(model)`.
|
||||||
|
- Update the two existing callers to compile (named pass-through added in
|
||||||
|
Tasks 3–4; keep default null here).
|
||||||
|
|
||||||
|
## Task 3 — Planning + improvement MCP tools forward model
|
||||||
|
|
||||||
|
- `PlanningMcpService.CreateChildTask`: add `string? model` param after
|
||||||
|
`commitType`; pass to `CreateChildAsync`. Extend `[Description]` to document
|
||||||
|
the model arg (haiku/sonnet/opus; cheapest capable).
|
||||||
|
- `TaskRunMcpService.SuggestImprovement`: add `string? model` param after
|
||||||
|
`description`; pass to `CreateChildAsync`. Extend `[Description]`.
|
||||||
|
- Tests: each tool persists the model; invalid value throws.
|
||||||
|
|
||||||
|
## Task 4 — External AddTask forwards model
|
||||||
|
|
||||||
|
- `ExternalMcpService.AddTask`: add `string? model = null` param (before the
|
||||||
|
trailing `CancellationToken`); `entity.Model = ModelRegistry.NormalizeAlias(model)`.
|
||||||
|
Extend `[Description]`.
|
||||||
|
- Test: AddTask persists model; invalid value rejected.
|
||||||
|
|
||||||
|
## Task 5 — Prompt guidance
|
||||||
|
|
||||||
|
- `PromptFiles.PlanningSystemDefault`: add a short paragraph — assign each
|
||||||
|
subtask the cheapest model that does it well, with ordering haiku < sonnet <
|
||||||
|
opus and the heuristic; pass it as `CreateChildTask(model=...)`.
|
||||||
|
- `PromptFiles.SystemDefault` Out-of-scope section: when filing via
|
||||||
|
`SuggestImprovement`, pass the cheapest capable `model`.
|
||||||
|
- `PromptFiles.ImprovementChildDefault`: one-line minimality reminder.
|
||||||
|
- No test (static prompt text); verify build only.
|
||||||
|
|
||||||
|
## Task 6 — Verify
|
||||||
|
|
||||||
|
- Build App + Worker `-c Release`; run Worker.Tests + Data.Tests.
|
||||||
|
- Update `ClaudeDo.Worker/CLAUDE.md` (ConfigMcpTools/creation-tool notes) and
|
||||||
|
`ClaudeDo.Data/CLAUDE.md` (ModelRegistry) if needed.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Plan — Unify the parent-task model
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-09-unify-parent-task-model-design.md`
|
||||||
|
|
||||||
|
Subagents: `sonnet`. Stage files explicitly by path (never `git add -A`). TDD.
|
||||||
|
Build with `-c Release` per project. Commit per task (Conventional Commits).
|
||||||
|
|
||||||
|
## Task 1 — Single parent-advance path
|
||||||
|
|
||||||
|
- Rename `TaskStateService.TryAdvanceImprovementParentAsync` → `TryAdvanceParentAsync`.
|
||||||
|
- Make it advance **any** `WaitingForChildren` parent → `WaitingForReview` when all
|
||||||
|
children are terminal, and advance a parent with **zero** children straight to
|
||||||
|
`WaitingForReview`.
|
||||||
|
- In `OnChildTerminalAsync`: drop the `TryCompleteParentAsync` call; keep
|
||||||
|
`_chain.OnChildFinishedAsync`; call the renamed advance method for all parents.
|
||||||
|
- Tests: extend `WaitingForChildrenLifecycleTests` — (a) improvement parent still
|
||||||
|
advances; (b) a `WaitingForChildren` parent whose children are a *sequential chain*
|
||||||
|
advances only after the last one is terminal; (c) zero-children parent advances.
|
||||||
|
|
||||||
|
## Task 2 — Delete `TryCompleteParentAsync`
|
||||||
|
|
||||||
|
- Remove `TaskRepository.TryCompleteParentAsync` (`TaskRepository.cs:477-502`) and
|
||||||
|
any remaining references.
|
||||||
|
- Update `src/ClaudeDo.Data/CLAUDE.md` (drop it from the TaskRepository helper list).
|
||||||
|
- Build Data + Worker; fix references.
|
||||||
|
|
||||||
|
## Task 3 — Planning finalize enters `WaitingForChildren`
|
||||||
|
|
||||||
|
- `TaskStateService.FinalizePlanningAsync`: in the same `ExecuteUpdateAsync`, set
|
||||||
|
`Status = WaitingForChildren` alongside `PlanningPhase = Finalized` /
|
||||||
|
`PlanningFinalizedAt`.
|
||||||
|
- Verify `PlanningSessionManager.FinalizeAsync` ordering: finalize (→ WaitingForChildren)
|
||||||
|
**before** `SetupChainAsync` enqueues child[0]. Adjust only if ordering is wrong.
|
||||||
|
- Tests: finalizing a planning parent with N children leaves it `WaitingForChildren`;
|
||||||
|
after the chain completes it is `WaitingForReview` (not `Done`); a planning parent
|
||||||
|
with zero finalized children lands in `WaitingForReview`.
|
||||||
|
|
||||||
|
## Task 4 — Approve merges the whole unit
|
||||||
|
|
||||||
|
**Decision: full UX consolidation.** Approve becomes the single entry for reviewing
|
||||||
|
*and* merging any task; the separate planning-merge views are folded into the review
|
||||||
|
panel. The `PlanningMergeOrchestrator` (which already merges the unit + sets the
|
||||||
|
parent `Done` for both planning and improvement, with conflict continue/abort) is
|
||||||
|
reused as the engine; only its *entry/UI* moves.
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
- `WorkerHub.ApproveReview`: for a parent that **has children**, drive
|
||||||
|
`PlanningMergeOrchestrator.StartAsync` (event-based: `PlanningMergeStarted` /
|
||||||
|
`PlanningSubtaskMerged` / `PlanningMergeConflict` / `PlanningMergeAborted` /
|
||||||
|
`PlanningCompleted`) instead of the one-shot `ApproveAndMergeAsync`. Childless tasks
|
||||||
|
keep `ApproveAndMergeAsync`. Conflict resolution still goes through
|
||||||
|
`ContinuePlanningMerge` / `AbortPlanningMerge`.
|
||||||
|
- Keep the orchestrator, `ContinuePlanningMerge`, `AbortPlanningMerge`,
|
||||||
|
`GetPlanningAggregate`, `BuildPlanningIntegrationBranch`. Remove the now-redundant
|
||||||
|
standalone `MergeAllPlanning` hub method (approve is the entry).
|
||||||
|
- (Optional cleanup) route the orchestrator's `FinalizeParentDoneAsync` through
|
||||||
|
`TaskStateService` so `Status` writes stay centralized; low priority.
|
||||||
|
|
||||||
|
UI (Avalonia, MVVM — visual-verification gaps, flag for user):
|
||||||
|
- The review panel (`DetailsIslandViewModel` / its view) is the single approve+merge
|
||||||
|
surface. For a child-bearing parent in `WaitingForReview`, approve shows the
|
||||||
|
unit-merge progress + per-subtask state, the aggregate/integration diff preview, and
|
||||||
|
conflict continue/abort — all inline in the review panel.
|
||||||
|
- Remove the separate planning-merge view(s)/commands and the standalone "Merge all"
|
||||||
|
button; re-wire their `PlanningMerge*` event handlers into the review panel VM.
|
||||||
|
- Sync `IWorkerClient` + hand-rolled test fakes in both UI/Worker test projects.
|
||||||
|
|
||||||
|
Tests: approving a parent with two `Done` children merges both then sets `Done`; a
|
||||||
|
conflicting second child surfaces the conflict and pauses (continue/abort) without
|
||||||
|
losing the parent's `WaitingForReview`/merge state.
|
||||||
|
|
||||||
|
## Task 5 — Cancellable `WaitingForChildren` parent
|
||||||
|
|
||||||
|
- Add `TaskStatus.WaitingForChildren` to the `CancelAsync` guard.
|
||||||
|
- Test: a parent in `WaitingForChildren` can be cancelled.
|
||||||
|
|
||||||
|
## Task 6 — Docs
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Worker/CLAUDE.md`: add `WaitingForChildren` to the Status table +
|
||||||
|
transition diagram; document the unified parent flow and approve-merges-unit;
|
||||||
|
remove `MergeAllPlanning` from the Hub method list.
|
||||||
|
- `src/ClaudeDo.Data/CLAUDE.md`: add `WaitingForChildren` to the TaskEntity status list.
|
||||||
|
- Root `CLAUDE.md`: update the "Task status flow" convention line.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
- `dotnet test` for Worker.Tests + Data.Tests (`-c Release`).
|
||||||
|
- UI flows (planning finalize → review → approve-merge; improvement parent;
|
||||||
|
retired MergeAllPlanning button) are **visual-verification gaps** — flag for the
|
||||||
|
user to run the app; do not claim they work from tests alone.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Online Inbox — implementation plan
|
||||||
|
|
||||||
|
Date: 2026-06-10
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-10-online-inbox-design.md`
|
||||||
|
Contract: `docs/online-inbox-api-contract.md`
|
||||||
|
|
||||||
|
TDD, one commit per task, Conventional Commits. Build with `-c Release` per CLAUDE.md.
|
||||||
|
|
||||||
|
## Phase 1 — Worker sync engine (buildable now, no Zitadel package needed)
|
||||||
|
|
||||||
|
### Task 1 — Config
|
||||||
|
- Add `OnlineInboxConfig` + nested `ZitadelClientConfig` records.
|
||||||
|
- Add `online_inbox` (`OnlineInbox`) property to `WorkerConfig`; default `enabled=false`.
|
||||||
|
- `Load` leaves it untouched when absent (defaults = disabled).
|
||||||
|
- Test: missing section → disabled defaults; populated section round-trips.
|
||||||
|
|
||||||
|
### Task 2 — DTOs + Idle-backlog helper
|
||||||
|
- `Online/Dtos.cs`: `RemoteList(Id, Name)`, `RemoteTask(Id, ListId, Title, Description, CreatedAt)`,
|
||||||
|
`MirrorTask(Id, ListId, Title, Description)`.
|
||||||
|
- `Online/OnlineBacklog.cs`: `static Task<List<MirrorTask>> CurrentAsync(TaskRepository/ctx)` +
|
||||||
|
the filter predicate (Idle, no parent, PlanningPhase None, BlockedBy null).
|
||||||
|
- Test the filter against real SQLite seeded with mixed tasks.
|
||||||
|
|
||||||
|
### Task 3 — Auth abstraction + token store
|
||||||
|
- `Online/Interfaces/IOnlineAuthProvider.cs`.
|
||||||
|
- `Online/OnlineTokenStore.cs`: DPAPI CurrentUser persistence at `~/.todo-app/online-inbox.token`;
|
||||||
|
`Save(refreshToken)`, `Read()`, `Clear()`. (Windows-only encryption; thin + guarded.)
|
||||||
|
- A trivial `StaticTokenAuthProvider` (returns a configured token or null) for tests + as the
|
||||||
|
temporary default until Zitadel is wired.
|
||||||
|
- Test: token store round-trip (Windows); static provider returns/omits token.
|
||||||
|
|
||||||
|
### Task 4 — API client
|
||||||
|
- `Online/IOnlineInboxApi.cs` + `Online/OnlineInboxApiClient.cs` (typed `HttpClient`).
|
||||||
|
- Attaches `Authorization: Bearer` from `IOnlineAuthProvider`; refuses non-HTTPS non-loopback
|
||||||
|
base URLs; throws a typed `OnlineInboxException` on non-2xx.
|
||||||
|
- Test with a stubbed `HttpMessageHandler`: each method hits the right path/verb/body; 401
|
||||||
|
surfaces; bearer attached.
|
||||||
|
|
||||||
|
### Task 5 — Sync service
|
||||||
|
- `Online/OnlineSyncService.cs` (`BackgroundService`) implementing the §5 reconcile loop.
|
||||||
|
- DI: register only when `enabled`; resolve repos per-cycle via a scope.
|
||||||
|
- Per-cycle try/catch + structured logging; skip when no token; unknown-list skip.
|
||||||
|
- Test against a **fake `IOnlineInboxApi`** + real SQLite: pull→import→flag creates local Idle
|
||||||
|
tasks; mirror payload == Idle backlog; lists pushed; unknown list skipped & not flagged;
|
||||||
|
disabled/no-token = no api calls.
|
||||||
|
|
||||||
|
### Task 6 — Wire-up + docs
|
||||||
|
- Register the stack in `Program.cs` behind the enabled flag.
|
||||||
|
- Update `src/ClaudeDo.Worker/CLAUDE.md` (new `Online/` area) and `src/ClaudeDo.Worker/Config`
|
||||||
|
notes. Add `online_inbox` to the config section.
|
||||||
|
|
||||||
|
## Phase 2 — UI + real auth (AFTER the VPS reports client config)
|
||||||
|
|
||||||
|
### Task 7 — Hub + config plumbing
|
||||||
|
- Hub: `GetOnlineInboxConfig` / `SetOnlineInboxConfig` / `SetOnlineInboxAuth(refreshToken)` /
|
||||||
|
`ClearOnlineInboxAuth`. Update `IWorkerClient` + `WorkerClient` + test fakes (both test
|
||||||
|
projects — see the IWorkerClient-fakes memory).
|
||||||
|
|
||||||
|
### Task 8 — Settings UI
|
||||||
|
- "Online Inbox" section in `SettingsModalViewModel`: enable toggle, base URL, Sign in/out,
|
||||||
|
status. Localized keys in en.json + de.json (parity).
|
||||||
|
- Visual verification = manual (flag it).
|
||||||
|
|
||||||
|
### Task 9 — ZitadelAuthProvider
|
||||||
|
- Add the Zitadel package reference; implement `ZitadelAuthProvider` (refresh-token → access
|
||||||
|
token, cached to expiry) using the reported authority/client-id/flow.
|
||||||
|
- Swap it in for `StaticTokenAuthProvider` in DI when enabled.
|
||||||
|
- Manual smoke against the live VPS API (tracked, not an automated test).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- No real network / no real Zitadel / no real Claude in any automated test.
|
||||||
|
- Stage files by explicit path in subagents; sonnet model; build+test+commit by the orchestrator.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Feature unification — phased plan
|
||||||
|
|
||||||
|
Date: 2026-06-19
|
||||||
|
Design: `docs/superpowers/specs/2026-06-19-feature-unification-design.md`
|
||||||
|
|
||||||
|
Six slices, sequenced cheapest/lowest-risk first. Each ends green
|
||||||
|
(`dotnet build -c Release` + the touched test project) and is independently
|
||||||
|
committable. Phases 0–1 are detailed here; 2–5 are scoped, and each gets its own
|
||||||
|
`docs/superpowers/plans/2026-06-19-unify-<slice>.md` when picked up (per the
|
||||||
|
2026-06-05 layer-A/B/C convention). Build per-csproj (`-c Release`) — `.slnx` needs
|
||||||
|
.NET 9 and a running Worker locks `Debug`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Groundwork (Bucket C). No UX change.
|
||||||
|
|
||||||
|
**0a. Delete the dead hunks conflict API (C1).**
|
||||||
|
- Remove `TaskMergeService.GetConflictsAsync` + the `MergeConflicts`/`ConflictFileContent` records it returns (`src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs:250`) if unused elsewhere.
|
||||||
|
- Remove `WorkerHub.GetMergeConflicts` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:378`) + `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` if unused.
|
||||||
|
- Remove `WorkerClient`'s `"GetMergeConflicts"` invoke (`src/ClaudeDo.Ui/Services/WorkerClient.cs:276`) + the `IWorkerClient` member + every fake override (`tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `TasksIslandViewModelPlanningTests.cs`, others — grep `GetMergeConflicts`).
|
||||||
|
- Delete `TaskMergeServiceTests.cs:672` `GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs`.
|
||||||
|
- Verify with grep first: `GetConflictsAsync` and `GetMergeConflicts` have **no** callers outside this chain + tests.
|
||||||
|
- Acceptance: Worker + Ui build; Worker.Tests + Ui.Tests green; `GetMergeConflictDocuments` path untouched.
|
||||||
|
|
||||||
|
**0b. Single task-creation path (C2).**
|
||||||
|
- Identify the path MCP `ExternalMcpService.AddTask` uses; expose a thin creation method (repository or a small `TaskCreationService`) that applies the same defaults (ListId, SortOrder, CreatedAt).
|
||||||
|
- Re-point `TasksIslandViewModel.AddAsync` at it instead of `db.Tasks.Add` direct EF.
|
||||||
|
- Acceptance: quick-add still works; one creation path; Ui.Tests + Worker.Tests green.
|
||||||
|
|
||||||
|
**0c. Prune stale worktrees (C3).**
|
||||||
|
- `git worktree list`; remove the orphaned `.claude/worktrees/*` entries (confirm each is unwanted with Mika before `git worktree remove`).
|
||||||
|
- Acceptance: only intended worktrees remain; no tracked files change.
|
||||||
|
|
||||||
|
> C4 (naming alignment) intentionally NOT in this phase — see design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — DialogService (B3–B5). Low–medium.
|
||||||
|
|
||||||
|
**Goal:** one `IDialogService` replaces the scattered `Show*` Func seams and the
|
||||||
|
duplicate open-commands.
|
||||||
|
|
||||||
|
- New `IDialogService` (Ui/Services) with typed methods: `OpenListSettings(ListNavItemViewModel)`, `OpenRepoImport()`, `OpenWorktreesOverview(string? listId)`, `OpenWeeklyReport()`, `OpenAbout()`, `OpenWorkerConnectionHelp()`. Implementation owns the factories + `ModalShell`/TCS wiring currently in `MainWindow.axaml.cs` + `IslandsShellViewModel.cs:59-71`.
|
||||||
|
- Inject it into `ListsIslandViewModel`, `TasksIslandViewModel`, `IslandsShellViewModel`. Collapse the three List-Settings doors (Lists context menu, Tasks header, shell bridge `IslandsShellViewModel.cs:190-194`) to one `dialogs.OpenListSettings(row)` call; same for Repo Import (2→1) and Worktrees Overview (2→1, keep the `listId?` param for global-vs-per-list).
|
||||||
|
- Keep `ModalShell`/TCS dialog pattern; this only centralizes *opening*.
|
||||||
|
- Update fakes/ctors per the IWorkerClient-fakes hazard (ctor changes ripple to Ui.Tests).
|
||||||
|
- Acceptance: every dialog opens via one method; no duplicate open-commands; Ui.Tests green; visual gap flagged (open each dialog from each former door).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — MergeCoordinator (B1). Medium.
|
||||||
|
|
||||||
|
**Goal:** delete the five `RequestConflictResolution` seams; one coordinator.
|
||||||
|
|
||||||
|
- New `IMergeCoordinator` (Ui) `MergeAsync(taskId, targetBranch)` = the body of `IslandsShellViewModel.RequestConflictResolutionAsync` (`:49`) plus the "open MergeModal → on conflict open resolver" flow currently split across `MergeModalViewModel:108` and `DiffModalViewModel:103`.
|
||||||
|
- Remove the `Func<string,string,Task>? RequestConflictResolution` from `WorktreesOverviewModalViewModel:83`, `DiffModalViewModel:75`, `MergeModalViewModel:33`, `MergeSectionViewModel:51`, and the `DetailsIslandViewModel:347` delegate; inject the coordinator instead.
|
||||||
|
- Re-point doors: review Approve, Diff Merge button, WorktreesOverview single + batch (`:331`), Details merge section.
|
||||||
|
- Update seam tests (`WorktreesOverviewBatchMergeTests.cs:145`, `DetailsIslandConflictSeamTests.cs:84`) to assert via the coordinator.
|
||||||
|
- Acceptance: one merge entry API; resolver still opens for single-task AND planning conflict; Ui.Tests green; visual gap flagged (force a conflict from Approve and from the Diff Merge button).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — WorktreeActions (A3). Medium.
|
||||||
|
|
||||||
|
**Goal:** one per-task worktree-actions VM reused by overview rows + Details.
|
||||||
|
|
||||||
|
- New `WorktreeActionsViewModel(taskId)` with Merge/Diff/Discard/Keep/ForceRemove over `IWorkerClient` (uses the Phase-2 coordinator for Merge, the Phase-5 viewer for Diff — until then, current calls).
|
||||||
|
- `WorktreesOverviewModalViewModel` rows compose one each; `MergeSectionViewModel` hosts one for the active task. Remove the duplicated commands.
|
||||||
|
- Acceptance: both surfaces drive the same VM; Ui.Tests green; visual gap flagged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — AgentConfigEditor (A2). Medium.
|
||||||
|
|
||||||
|
**Goal:** one config editor for Global | List | Task scope.
|
||||||
|
|
||||||
|
- New `AgentConfigEditorViewModel(scope)` over `InheritanceResolver` exposing Model/SystemPrompt/AgentPath/MaxTurns + reset commands + `InheritedBadge` state; persists via the scope's hub method (`UpdateListConfig` / `UpdateTaskAgentSettings` / app settings).
|
||||||
|
- Embed in `SettingsModalViewModel`, `ListSettingsModalViewModel`, and the Details `AgentSettingsSectionViewModel` host; delete the duplicated field/reset logic.
|
||||||
|
- Acceptance: identical editor in all three scopes; Localization parity; Ui.Tests green; visual gap flagged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — DiffViewer (A1 + B2). High; last.
|
||||||
|
|
||||||
|
**Goal:** one diff component replaces DiffModal + WorktreeModal + PlanningDiff.
|
||||||
|
|
||||||
|
- New `DiffViewerViewModel` with `DiffSource` enum/abstraction (`DirtyWorktree | BranchVsBase | CommitRange | PlanningAggregate | IntegrationBranch`) and an optional file-tree pane (port `WorktreeModal`'s tree + Avalonia-12 selection workaround); reuse `UnifiedDiffParser` + `DiffLinesView`; keep PlanningDiff's combined-mode toggle as a source switch.
|
||||||
|
- Re-point all B2 doors to open it with the right source. Remove the three old VMs/views.
|
||||||
|
- Update `DiffModalViewModelTests`, `PlanningDiffViewModelTests`.
|
||||||
|
- Acceptance: every diff door opens the one viewer; whole-unified AND file-tree layouts work; Ui.Tests green; visual gap flagged (worktree-dirty, post-merge commit-range, planning per-subtask + integration).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sequencing rationale
|
||||||
|
|
||||||
|
0 (delete/no-UX) → 1 (isolated, unblocks nothing but cheap) → 2 (coordinator; 3 & 5
|
||||||
|
lean on it for Merge/Diff) → 3 → 4 (independent) → 5 (biggest, most UX-sensitive,
|
||||||
|
benefits from 2's coordinator). Stop after any phase and the app is shippable.
|
||||||
|
|
||||||
|
## Per-phase commits
|
||||||
|
|
||||||
|
Conventional Commits, one per phase (or per sub-step in Phase 0): e.g.
|
||||||
|
`refactor(merge): single MergeCoordinator replaces 5 conflict seams`. Stage by path
|
||||||
|
(never `git add -A` — concurrent sessions). Commit the spec + this plan first.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Plan: Rider-style 3-pane merge editor
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-19-rider-merge-editor-design.md`
|
||||||
|
|
||||||
|
TDD, one focused commit per task (Conventional Commits, `feat(merge): …`).
|
||||||
|
Build with `-c Release` per project (a running Worker locks `Debug`).
|
||||||
|
Run `ClaudeDo.Ui.Tests` (and `Localization.Tests` for Task 6). No real `claude` CLI in tests.
|
||||||
|
Stage ONLY the files each task touches, by explicit path (parallel sessions leave WIP).
|
||||||
|
Backend + seam stay unchanged. Implementer/reviewer subagents use **sonnet**.
|
||||||
|
|
||||||
|
## Task 1 — VM: active-file model + 3-pane reconstruction + readout
|
||||||
|
|
||||||
|
`ConflictResolverViewModel` / `ConflictModels.cs`, additive (seam untouched).
|
||||||
|
|
||||||
|
- Add `ActiveFile` (`MergeFile?`), `SelectFileCommand(MergeFile)`, default to first file
|
||||||
|
after load. Keep `Files`, `Current`/`CurrentIndex`/`Next`/`Previous` (focused conflict
|
||||||
|
for the header arrows), `CanContinue`, binary guard, planning routing — all unchanged.
|
||||||
|
- Add computed, per `ActiveFile`:
|
||||||
|
- `ActiveOursText` = concat(stable.Text | conflict.Ours)
|
||||||
|
- `ActiveTheirsText` = concat(stable.Text | conflict.Theirs)
|
||||||
|
- `ActiveResultText` = concat(stable.Text | conflict.Resolution ?? conflict.Ours)
|
||||||
|
- `ActiveConflicts` = ordered descriptors (block + segment index) for the view.
|
||||||
|
- `PositionText` → `"{conflicts} conflicts · {resolved} resolved"` for the active file;
|
||||||
|
keep `CanContinue` = every file resolved AND no binary.
|
||||||
|
- Switching files raises a change event the view listens to (reuse/extend
|
||||||
|
`CurrentChanged` → e.g. `ActiveFileChanged`).
|
||||||
|
- Tests (Ui.Tests): reconstruction text for ours/theirs/result (result seeds unresolved
|
||||||
|
with Ours); resolving a block updates `ActiveResultText` + readout; switching files
|
||||||
|
preserves each block's `Resolution`; `CanContinue` blocks until all files resolved;
|
||||||
|
binary file still blocks. Keep all existing tests green.
|
||||||
|
|
||||||
|
## Task 2 — View: 3-pane AXAML shell + document assembly + synced scroll
|
||||||
|
|
||||||
|
`Views/Conflicts/ConflictResolverView.axaml(.cs)`. Visual — verified by running.
|
||||||
|
|
||||||
|
- Replace AXAML: ModalShell host kept; header row (◀/▶ focus arrows bound to
|
||||||
|
Previous/Next, file switcher `ItemsControl`/`ComboBox` over `Files` bound to
|
||||||
|
`SelectFileCommand`, right-aligned `PositionText`); `Grid ColumnDefinitions="*,*,*"`
|
||||||
|
of three bordered panes with headers **Ours · current (merge target)** /
|
||||||
|
**Result** / **Theirs · incoming (task)** (drop Base); footer Continue
|
||||||
|
(`IsEnabled=CanContinue`) / Abort; binary banner (kept); `Escape`→Abort (kept).
|
||||||
|
- Code-behind: build three `TextDocument`s from `ActiveFile` segments, recording each
|
||||||
|
conflict's start line + line count per document; install TextMate per pane by file
|
||||||
|
extension; rebuild on `ActiveFileChanged`; Ours/Theirs `IsReadOnly=true`.
|
||||||
|
- Proportional synced vertical scroll across the three panes (re-entrancy guard).
|
||||||
|
- Push Result edits back to the active block `Resolution` (refined in Task 4).
|
||||||
|
|
||||||
|
## Task 3 — Result pane: read-only stable, editable conflicts
|
||||||
|
|
||||||
|
`ConflictResolverView.axaml.cs` + a small `IReadOnlySectionProvider` helper.
|
||||||
|
|
||||||
|
- Track each conflict's result span in a `TextSegmentCollection<…>` over the Result
|
||||||
|
document (anchors auto-adjust on edit).
|
||||||
|
- `IReadOnlySectionProvider`: `CanInsert` only strictly inside a conflict span;
|
||||||
|
`GetDeletableSegments` intersects with conflict spans only. Stable text becomes
|
||||||
|
immutable; conflict regions stay editable.
|
||||||
|
- Editing inside a conflict span writes the span text back to the block `Resolution`
|
||||||
|
and flips it resolved (updates readout + `CanContinue`).
|
||||||
|
|
||||||
|
## Task 4 — Color blocks (IBackgroundRenderer) + accept overlay
|
||||||
|
|
||||||
|
`ConflictResolverView.axaml.cs` + renderer/overlay helpers.
|
||||||
|
|
||||||
|
- `IBackgroundRenderer` per pane: unresolved conflict = red (Blood tint), resolved =
|
||||||
|
green/muted, Ours side = Moss tint, Theirs side = Accent tint — driven by recorded
|
||||||
|
spans + block `IsResolved`.
|
||||||
|
- Between-pane overlay Canvas (Ours|Result and Result|Theirs): `›` accept-ours / `‹`
|
||||||
|
accept-theirs + `✕` dismiss per conflict, positioned at the block's `TextView` visual
|
||||||
|
top, recomputed on scroll/resize. Click → `block.AcceptOurs/AcceptTheirs` and replace
|
||||||
|
the tracked Result span; resolved blocks recolor.
|
||||||
|
|
||||||
|
## Task 5 — Polish: readout, focus arrows scroll-to-conflict, resolved styling
|
||||||
|
|
||||||
|
- ◀/▶ arrows move `Current` and scroll all three panes to that conflict.
|
||||||
|
- `M conflicts · K resolved` live readout; Continue tooltip/hint when blocked.
|
||||||
|
- Resolved conflict recolors and drops its accept overlay; unresolved stays red.
|
||||||
|
(Fold into Task 4 if small.)
|
||||||
|
|
||||||
|
## Task 6 — Localization + tokens
|
||||||
|
|
||||||
|
- Add `conflictResolver.*` keys (pane headers, readout, accept tooltips, hints) to
|
||||||
|
`locales/en.json` AND `locales/de.json` (keep key parity).
|
||||||
|
- Add Tokens.axaml color tokens only if a needed conflict/resolved shade is missing.
|
||||||
|
- Run Localization.Tests (parity) + a quick scan for hard-coded strings in the view.
|
||||||
|
|
||||||
|
## Task 7 — Verify
|
||||||
|
|
||||||
|
- Build `ClaudeDo.App` + `ClaudeDo.Ui` `-c Release`; run `Ui.Tests` + `Localization.Tests`.
|
||||||
|
- Update `src/ClaudeDo.Ui/CLAUDE.md` (Planning/Conflicts paragraph → new 3-pane editor).
|
||||||
|
- **Visual verification gap (flag to Mika):** run the app, trigger a real conflict
|
||||||
|
(single-task approve + planning unit-merge) and confirm panes/colors/accept/scroll/
|
||||||
|
gating/binary render correctly — cannot be asserted in tests.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Phase 4 — AgentConfigEditor (A2)
|
||||||
|
|
||||||
|
Date: 2026-06-23 (picked up after reordering Phase 3 ↔ 4)
|
||||||
|
Umbrella: `docs/superpowers/plans/2026-06-19-feature-unification-plan.md`
|
||||||
|
Design: `docs/superpowers/specs/2026-06-19-feature-unification-design.md` (A2)
|
||||||
|
|
||||||
|
## Reordering note
|
||||||
|
|
||||||
|
Phase 3 (WorktreeActions) was deferred. Its premise — overview rows and the Details
|
||||||
|
merge section each owning duplicate worktree commands — only half-holds: Details has
|
||||||
|
no Discard/Keep/ForceRemove, and the two Diff doors open different VMs (`WorktreeModal`
|
||||||
|
vs `DiffModal`) that only Phase 5 unifies. So Phase 3's clean form depends on Phase 5
|
||||||
|
(Diff) and a fuller MergeCoordinator (Merge); doing it now would build throwaway
|
||||||
|
per-surface delegates. **Phase 3 is folded into Phase 5.** Phase 4 (independent, clean
|
||||||
|
dedup) runs now.
|
||||||
|
|
||||||
|
## Scope decision: List + Task only (global left as-is)
|
||||||
|
|
||||||
|
The design names three scopes (Global | List | Task). Verified against the tree on
|
||||||
|
2026-06-23, only **List and Task genuinely duplicate**:
|
||||||
|
|
||||||
|
- **List** (`ListSettingsModalViewModel`, "AGENT" section): Model / MaxTurns /
|
||||||
|
SystemPrompt / AgentFile, each with `InheritedBadge` + `↺` reset; 2-tier
|
||||||
|
(list→global) badges computed with inline logic (does **not** use the existing
|
||||||
|
`InheritanceResolver.ResolveList` — which is currently dead code); explicit Save.
|
||||||
|
- **Task** (`AgentSettingsSectionViewModel`, TaskHeaderBar gear flyout): same four
|
||||||
|
fields; 3-tier (task→list→global) badges via `InheritanceResolver.Resolve`;
|
||||||
|
`EffectiveMaxTurns` + `EffectiveSystemPromptHint`; `IsRunning` gate; debounced
|
||||||
|
auto-save.
|
||||||
|
|
||||||
|
**Global** (`GeneralSettingsTabViewModel`, Settings → General) is the root: no
|
||||||
|
inheritance, no badges, no agent file, no reset — three plain controls (model combo,
|
||||||
|
max-turns numeric, instructions textbox) plus a global-only PermissionMode, interleaved
|
||||||
|
with unrelated settings (Language, parallelism, report paths, standup weekday) and
|
||||||
|
saved batched into one `AppSettingsDto` via the modal Save. Embedding the shared editor
|
||||||
|
there buys ~3 plain fields at the cost of a degenerate no-badges/no-agent/no-reset mode
|
||||||
|
plus surgery on the settings save path and a relayout of the most settings-dense view.
|
||||||
|
**Not worth it — global stays as-is.** (Confirmed with Mika 2026-06-23.)
|
||||||
|
|
||||||
|
The real maintenance hazard is the **VM logic** (two copies of badge/reset/inheritance
|
||||||
|
that already drifted), and the **view** (3 of 4 field blocks are pixel-identical). Both
|
||||||
|
collapse cleanly for List+Task.
|
||||||
|
|
||||||
|
## Target
|
||||||
|
|
||||||
|
One `AgentConfigEditorViewModel` + one `AgentConfigEditor` UserControl, instantiated
|
||||||
|
per surface with a scope. The two host VMs keep only their non-agent concerns and host
|
||||||
|
the editor as a child.
|
||||||
|
|
||||||
|
### `ViewModels/Agent/AgentConfigEditorViewModel.cs` (new)
|
||||||
|
|
||||||
|
- `enum AgentConfigScope { List, Task }`
|
||||||
|
- ctor `(IWorkerClient worker, AgentConfigScope scope)`
|
||||||
|
- Unified bindable surface (single names both views bind to):
|
||||||
|
`Model` (string?), `MaxTurns` (decimal?), `SystemPrompt` (string),
|
||||||
|
`SelectedAgent` (AgentInfo?); `ModelOptions`, `Agents`;
|
||||||
|
`ModelBadge`/`TurnsBadge`/`AgentBadge`, `ModelInheritedHint`/`TurnsInheritedHint`,
|
||||||
|
`EffectiveSystemPromptHint`; `EffectiveMaxTurns` (int), `IsRunning`/`IsEnabled`.
|
||||||
|
- Reset commands: `ResetModel`, `ResetTurns`, `ResetAgent`, `ResetAll`.
|
||||||
|
- Badges via `InheritanceResolver`: scope==Task → `Resolve(own, list, global)`;
|
||||||
|
scope==List → `ResolveList(own, global)` (adopts the dead method). One `BadgeFor`
|
||||||
|
helper covers both (List scope never yields the `List` source).
|
||||||
|
- Load: `LoadForListAsync(listId)` and `LoadForTaskAsync(TaskEntity entity)` — both
|
||||||
|
pull agents + app-settings (global defaults); Task also pulls the list tier +
|
||||||
|
`EffectiveSystemPromptHint`. Localizer-change re-badges (port the `Loc.LanguageChanged`
|
||||||
|
handler + `IDisposable`).
|
||||||
|
- Save: `SaveAsync()` is scope-aware — List builds `UpdateListConfigDto` →
|
||||||
|
`UpdateListConfigAsync`; Task builds `UpdateTaskAgentSettingsDto` →
|
||||||
|
`UpdateTaskAgentSettingsAsync`. Task scope also auto-saves debounced (300ms) on field
|
||||||
|
changes; List does not (the modal Save button calls `SaveAsync`). `SaveAsync` is
|
||||||
|
directly callable (tests bypass the debounce).
|
||||||
|
- Task-only `Clear()` + `TaskId`.
|
||||||
|
|
||||||
|
### `Views/Controls/AgentConfigEditor.axaml` (+ .axaml.cs) (new)
|
||||||
|
|
||||||
|
- `x:DataType` = `AgentConfigEditorViewModel`; host sets `DataContext="{Binding Agent}"`.
|
||||||
|
- The four field blocks (model/turns/systemprompt/agent) with `InheritedBadge` + `↺`
|
||||||
|
reset, lifted verbatim from the existing two views (they already match). Agent combo
|
||||||
|
shows Name + Description (both scopes; harmless for task). `EffectiveSystemPromptHint`
|
||||||
|
line gated on non-empty (hides for List).
|
||||||
|
- `StyledProperty<bool> ShowAgentBrowse` (default false). True → render the Browse
|
||||||
|
button + path line; the browse file-picker code-behind lives here (moved from
|
||||||
|
`ListSettingsModalView`).
|
||||||
|
- Shared localization namespace `settings.agentEditor.*` (model/maxTurns/systemPrompt/
|
||||||
|
agentFile/promptPrepended). Reset tooltip reuses `settings.inherit.resetToInherited`.
|
||||||
|
|
||||||
|
### Re-point hosts
|
||||||
|
|
||||||
|
- `ListSettingsModalViewModel`: drop the agent fields/badges/resets/option-lists; add
|
||||||
|
`public AgentConfigEditorViewModel Agent { get; }` (scope=List). `LoadAsync` →
|
||||||
|
`Agent.LoadForListAsync(listId)`. `SaveAsync` keeps `UpdateListAsync` (name/dir) and
|
||||||
|
adds `await Agent.SaveAsync()`. Keep working-dir browse (`BrowseClicked`).
|
||||||
|
- `ListSettingsModalView.axaml`: replace the AGENT section body with
|
||||||
|
`<ctl:AgentConfigEditor DataContext="{Binding Agent}" ShowAgentBrowse="True"/>`; the
|
||||||
|
section-header "Reset agent settings" button binds `Agent.ResetAllCommand`. Remove the
|
||||||
|
agent browse code-behind (moved into the control).
|
||||||
|
- `DetailsIslandViewModel`: `AgentSettings` becomes `AgentConfigEditorViewModel`
|
||||||
|
(scope=Task). Preserve the call sites: ctor, `EffectiveMaxTurns`→`TurnsText`
|
||||||
|
PropertyChanged hook, `IsRunning` push, `Dispose`, `Clear`, `TaskId`,
|
||||||
|
`LoadForTaskAsync(entity, ct)`.
|
||||||
|
- `TaskHeaderBar.axaml`: replace the flyout field blocks with
|
||||||
|
`<ctl:AgentConfigEditor DataContext="{Binding AgentSettings}"/>` (ShowAgentBrowse=false).
|
||||||
|
Keep the gear button + heading.
|
||||||
|
- Delete `AgentSettingsSectionViewModel.cs`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- New `tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs`:
|
||||||
|
- List scope: badges resolve override-vs-global; resets clear; `SaveAsync` builds the
|
||||||
|
right `UpdateListConfigDto` (via `StubWorkerClient`).
|
||||||
|
- Task scope: badges resolve override/list/global; `EffectiveMaxTurns`/
|
||||||
|
`EffectiveSystemPromptHint` from list tier; resets clear; `SaveAsync` builds the right
|
||||||
|
`UpdateTaskAgentSettingsDto`.
|
||||||
|
- `InheritanceResolverTests` unchanged (resolver untouched).
|
||||||
|
- Existing DetailsIsland* tests must stay green (they construct the VM but don't name the
|
||||||
|
moved members).
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- `dotnet build -c Release` clean for Ui (+ App).
|
||||||
|
- `Ui.Tests` + `Localization.Tests` green.
|
||||||
|
- One editor VM + one control drive both List and Task; duplicated field/badge/reset
|
||||||
|
logic deleted; `ResolveList` now has a real caller.
|
||||||
|
- Visual gap flagged: open List Settings → Agent, and a task's gear flyout — verify
|
||||||
|
badges, ↺ resets, reset-all, agent browse (list only), system-prompt hint (task), and
|
||||||
|
that list Save persists + task auto-saves.
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
`refactor(agent-config): single AgentConfigEditor for list + task scopes`. Stage by
|
||||||
|
path. Commit this plan with it.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Phase 5 — DiffViewer (A1 + B2)
|
||||||
|
|
||||||
|
Date: 2026-06-23
|
||||||
|
Umbrella: `docs/superpowers/plans/2026-06-19-feature-unification-plan.md`
|
||||||
|
Design: `docs/superpowers/specs/2026-06-19-feature-unification-design.md` (A1, B2)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
One diff component replaces the three parallel read-only diff windows:
|
||||||
|
`DiffModalViewModel`/View, `WorktreeModalViewModel`/View, `PlanningDiffViewModel`/View.
|
||||||
|
**Merge editor (`ConflictResolverViewModel`) is untouched** — per the design's hard
|
||||||
|
decision; the viewer only *opens* it on conflict via the existing Merge flow.
|
||||||
|
|
||||||
|
All three are already master-detail: **left nav pane + right `DiffLinesView`**. They
|
||||||
|
differ only in left-pane content, chrome, and data source — so they collapse into one
|
||||||
|
shell with a source mode.
|
||||||
|
|
||||||
|
## Decisions (Mika, 2026-06-23)
|
||||||
|
|
||||||
|
- **File nav = file-tree** (folder-grouped), not a flat list. Port `WorktreeModal`'s tree
|
||||||
|
+ the Avalonia-12 `TreeView.SelectionChanged` workaround. Carry per-file status + +adds/
|
||||||
|
−dels into the tree rows (from the parsed `DiffFileViewModel`).
|
||||||
|
- Planning keeps its **subtask-list + combined-mode toggle**; the branch source keeps its
|
||||||
|
**Merge** button.
|
||||||
|
|
||||||
|
## Target
|
||||||
|
|
||||||
|
### Shared types → `ViewModels/Modals/DiffModels.cs` (new, same namespace)
|
||||||
|
|
||||||
|
Move out of the to-be-deleted VMs so `UnifiedDiffParser`/`DiffLinesView` keep compiling:
|
||||||
|
`DiffLineKind`, `DiffFileStatus`, `DiffLineViewModel`, `DiffFileViewModel` (from
|
||||||
|
`DiffModalViewModel.cs`), `SubtaskDiffRow` (from `PlanningDiffViewModel.cs`). Add new
|
||||||
|
`DiffTreeNodeViewModel` (dir/file node; file leaves hold their `DiffFileViewModel`).
|
||||||
|
|
||||||
|
### `DiffViewerViewModel` (`ViewModels/Modals/DiffViewerViewModel.cs`, new)
|
||||||
|
|
||||||
|
ctor `(GitService git, IWorkerClient worker)`. A `DiffViewerMode { Files, Planning }`.
|
||||||
|
|
||||||
|
- **File sources** (replaces DiffModal + WorktreeModal): config props `WorktreePath`,
|
||||||
|
`BaseRef`, `HeadCommit`, `FromCommitRange`, `TaskId`, `TaskTitle` + `ShowMergeModal`/
|
||||||
|
`ResolveMergeVm` delegates. `LoadAsync` pulls the whole diff via GitService
|
||||||
|
(`GetCommitRangeDiffAsync` | `GetBranchDiffAsync` | `GetDiffAsync`), parses with
|
||||||
|
`UnifiedDiffParser.Parse`, builds `FileTree`. `SelectedNode` (leaf) → `SelectedFile`
|
||||||
|
(header + binary/empty placeholders + `Lines`). Commit-range null-guard → "no longer
|
||||||
|
available" (preserve DiffModal behavior). `MergeCommand` (CanMerge = TaskId +
|
||||||
|
delegates) opens the MergeModal, closes on merged/routed (verbatim from DiffModal).
|
||||||
|
- **Planning source** (replaces PlanningDiff): config `PlanningTaskId`, `TargetBranch`.
|
||||||
|
`LoadAsync` pulls `GetPlanningAggregateAsync` → `Subtasks`; `SelectedSubtask` →
|
||||||
|
`DisplayedDiff`; `IsCombinedMode` toggle → `BuildPlanningIntegrationBranchAsync`
|
||||||
|
(success → combined diff; conflict → `CombinedWarning` with subtask + file count;
|
||||||
|
null → hub-error warning). `DisplayedDiff` → flattened `DiffLines` (right pane).
|
||||||
|
- Shared: `StatusMessage`, `CloseAction`, `CloseCommand`.
|
||||||
|
|
||||||
|
### `DiffViewerView` (`Views/Modals/DiffViewerView.axaml` + `.cs`, new)
|
||||||
|
|
||||||
|
`ModalShell`-based window. Left pane: `TreeView` (Files mode) or subtask `ListBox`
|
||||||
|
(Planning mode), toggled by mode. Right pane: the DiffModal file pane (header + binary/
|
||||||
|
empty/no-changes placeholders + `DiffLinesView Lines="SelectedFile.Lines"`) in Files mode,
|
||||||
|
or `DiffLinesView Lines="DiffLines"` in Planning mode. Toolbar: combined toggle + warning
|
||||||
|
+ loading (Planning). Footer: Merge button (Files mode, CanMerge). Code-behind: `CloseAction`,
|
||||||
|
the `TreeView.SelectionChanged` → `SelectedNode` workaround, dir-row tap-to-expand.
|
||||||
|
|
||||||
|
### Re-point the 3 doors → one viewer
|
||||||
|
|
||||||
|
- **`MergeSectionViewModel`**: `OpenDiffAsync` builds a Files-mode `DiffViewerViewModel`
|
||||||
|
(+ ShowMergeModal/ResolveMergeVm) and calls a single `ShowDiffViewer` delegate;
|
||||||
|
`ReviewCombinedDiffAsync` builds a Planning-mode one and calls the *same* delegate.
|
||||||
|
Replaces `ShowDiffModal` + `ShowPlanningDiffModal` with one `Func<DiffViewerViewModel,Task>
|
||||||
|
ShowDiffViewer`; keeps `ShowMergeModal`. (Resolve the VM via `_services`.)
|
||||||
|
- **`DetailsIslandView.axaml.cs`**: replace the two `ShowDiffModal`/`ShowPlanningDiffModal`
|
||||||
|
wirings (→ `DiffModalView`/`PlanningDiffView`) with one `ShowDiffViewer` (→ `DiffViewerView`).
|
||||||
|
Keep `ShowMergeModal`.
|
||||||
|
- **`WorktreesOverviewModalViewModel`**: `ShowDiff` builds a Files-mode viewer (worktree path
|
||||||
|
+ base). Change `_diffVmFactory` from `Func<WorktreeModalViewModel>` to
|
||||||
|
`Func<DiffViewerViewModel>`; `ShowDiffAction` stays `Action<DiffViewerViewModel>`.
|
||||||
|
- **`WindowDialogService.cs`**: `ShowDiffAction` → `new DiffViewerView` + `LoadAsync` + show.
|
||||||
|
- **`Program.cs`**: register `DiffViewerViewModel` (transient) + `Func<DiffViewerViewModel>`;
|
||||||
|
drop the `WorktreeModalViewModel` registration.
|
||||||
|
|
||||||
|
### Delete
|
||||||
|
|
||||||
|
`DiffModalViewModel.cs`, `WorktreeModalViewModel.cs`, `PlanningDiffViewModel.cs`,
|
||||||
|
`DiffModalView.axaml(.cs)`, `WorktreeModalView.axaml(.cs)`, `PlanningDiffView.axaml(.cs)`.
|
||||||
|
|
||||||
|
### Localization
|
||||||
|
|
||||||
|
Reuse existing keys in the merged view (`modals.diff.*` for the file pane, `planning.diff.*`
|
||||||
|
for the planning toolbar). Prune clearly-orphaned `modals.worktree.*` if trivial; keep en/de
|
||||||
|
parity.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Replace `DiffModalViewModelTests` + `PlanningDiffViewModelTests` with
|
||||||
|
`DiffViewerViewModelTests` preserving the behaviors: commit-range null-guard → unavailable;
|
||||||
|
planning init populates + selects first; subtask select → DisplayedDiff; combined toggle
|
||||||
|
success/conflict/null. `WorktreesOverviewBatchMergeTests` compiles unchanged (`() => null!`
|
||||||
|
satisfies the new Func type). `UnifiedDiffParserTests` unchanged.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- `dotnet build -c Release` clean (App); `Ui.Tests` + `Localization.Tests` green.
|
||||||
|
- One viewer reached from all 3 doors; old VMs/views deleted; merge editor untouched.
|
||||||
|
- Visual gap flagged: Details "Open Diff" (dirty + post-merge commit-range), Worktrees-
|
||||||
|
Overview "Show Diff" (tree), Details "Review Combined Diff" (subtasks + combined toggle),
|
||||||
|
and the Merge button still opens the merge form / resolver on conflict.
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
`refactor(diff): single DiffViewer replaces DiffModal + WorktreeModal + PlanningDiff`.
|
||||||
|
Stage by path (exclude concurrent peers' files). Then Phase 3 (WorktreeActions) follows as
|
||||||
|
its own slice, reusing this viewer.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Plan — Worker log → footer + Log Visualizer overlay
|
||||||
|
|
||||||
|
Design: `docs/superpowers/specs/2026-06-23-worker-log-footer-overlay-design.md`. Build on `main`, TDD, commit per task (Conventional Commits, explicit paths — shared worktree). Build `-c Release`.
|
||||||
|
|
||||||
|
## Task 1 — `LogRingBuffer` (Worker) + tests
|
||||||
|
- `src/ClaudeDo.Worker/Logging/WorkerLogRecord.cs` — `record WorkerLogRecord(string Message, WorkerLogLevel Level, DateTime TimestampUtc)`.
|
||||||
|
- `src/ClaudeDo.Worker/Logging/LogRingBuffer.cs` — thread-safe, `TimeSpan window` + int cap; `Append(record)`, `Snapshot()`. Uses an injected clock func (`Func<DateTime>`) for testability (default `() => DateTime.UtcNow`).
|
||||||
|
- Tests: age eviction, cap eviction, snapshot order. **No `DateTime.UtcNow` in tests — drive the clock.**
|
||||||
|
|
||||||
|
## Task 2 — `BroadcastLogSink` (Worker) + tests
|
||||||
|
- `src/ClaudeDo.Worker/Logging/BroadcastLogSink.cs : ILogEventSink` — level map, render (+exception first line), append-all-levels, broadcast Warn/Err via deferred `HubBroadcaster` (`Attach`), dedupe window (const 120s), loop-guard (skip SignalR `SourceContext` for broadcast; swallow broadcast exceptions). Inject clock func.
|
||||||
|
- Broadcaster is an abstraction the test can fake: depend on a tiny `Func<string,WorkerLogLevel,DateTime,Task>?` set by `Attach`, OR on `HubBroadcaster` directly (it's a sealed class — prefer a delegate to keep the test pure). Use a delegate.
|
||||||
|
- Tests: all levels buffered; only Warn/Err invoke the broadcast delegate; dedupe suppresses 2nd identical within window but still buffers; exception rendering; SignalR-source event buffered but not broadcast.
|
||||||
|
|
||||||
|
## Task 3 — wire into `Program.cs` + `WorkerHub.GetRecentLogs`
|
||||||
|
- `Program.cs`: create `LogRingBuffer` + `BroadcastLogSink` locals before build; `.WriteTo.Sink(broadcastSink)`; `AddSingleton(logBuffer)`; after build `broadcastSink.Attach((m,l,t) => broadcaster.WorkerLog(m,l,t))` using resolved `HubBroadcaster`.
|
||||||
|
- `WorkerHub`: inject `LogRingBuffer`; `public IReadOnlyList<WorkerLogRecordDto> GetRecentLogs()` → snapshot mapped to DTO. Add `WorkerLogRecordDto` (Hub or shared). Update `WorkerHub` ctor → check hub-construction call sites/tests.
|
||||||
|
- Build Worker `-c Release`; run Worker.Tests (filtered to new + hub).
|
||||||
|
|
||||||
|
## Task 4 — `IWorkerClient.GetRecentLogsAsync` + WorkerClient + fakes
|
||||||
|
- `IWorkerClient` + `WorkerClient` impl (`_hub.InvokeAsync<List<WorkerLogEntry>>("GetRecentLogs", ct)`).
|
||||||
|
- Update fakes: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, Worker.Tests UiVm fake(s) → return `Array.Empty<WorkerLogEntry>()`.
|
||||||
|
- Build Ui + Worker.Tests.
|
||||||
|
|
||||||
|
## Task 5 — `LogVisualizerViewModel` + View + dialog wiring + tests
|
||||||
|
- VM (Modals/), View (Modals/, ModalShell), `IDialogService.ShowLogVisualizerAsync` + `WindowDialogService` impl.
|
||||||
|
- `IslandsShellViewModel.OpenLogVisualizerCommand` (resolves VM, loads, shows). Make footer worker-log line a clickable Button → command.
|
||||||
|
- Localization `vm.logVisualizer` en+de.
|
||||||
|
- Tests: VM load/populate/filter. Build App `-c Release`; Ui.Tests + Localization.Tests.
|
||||||
|
|
||||||
|
## Task 6 — verify + docs
|
||||||
|
- Full relevant test pass. Update `src/ClaudeDo.Ui/CLAUDE.md` (overlay VM/view, footer click) + `src/ClaudeDo.Worker/CLAUDE.md` (Logging/ folder, sink, GetRecentLogs, WorkerLog now carries Serilog Warn/Err). Note visual-verification gap (overlay render) for the user.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Plan — Interactive "Answer Claude's Questions"
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-25-interactive-ask-user-design.md`
|
||||||
|
|
||||||
|
Implement on the shared main tree. Commit explicit paths per task (never `git add -A`).
|
||||||
|
Build with `-c Release` (running Worker locks Debug). No real-Claude tests.
|
||||||
|
|
||||||
|
## Task 1 — PendingQuestionRegistry (worker, new file)
|
||||||
|
- `src/ClaudeDo.Worker/Runner/PendingQuestionRegistry.cs`: singleton; `record PendingQuestion(TaskId, QuestionId, Question)`.
|
||||||
|
- `(string QuestionId, Task<string> Answer) Register(taskId, question)` — overwrites any stale entry, `RunContinuationsAsynchronously`.
|
||||||
|
- `bool TryAnswer(taskId, questionId, answer)`; `PendingQuestion? Get(taskId)`; `void Remove(taskId, questionId)`.
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Runner/PendingQuestionRegistryTests.cs` — register→answer resolves the task; wrong questionId no-ops; Get reflects state; second Register overwrites.
|
||||||
|
|
||||||
|
## Task 2 — AskUser MCP tool (worker)
|
||||||
|
- `TaskRunMcpService.cs`: inject `PendingQuestionRegistry`; add
|
||||||
|
`[McpServerTool] async Task<string> AskUser(string question, CancellationToken ct)`:
|
||||||
|
- caller id from `_ctx.Current.CallerTaskId`; register; broadcast `TaskQuestionAsked`.
|
||||||
|
- await answer via `Task<string>.WaitAsync` with a 3-min linked-CTS; on timeout return the fallback string; on request-cancel rethrow.
|
||||||
|
- `finally`: `Remove` + broadcast `TaskQuestionResolved`.
|
||||||
|
- `[Description]`: when to use (only when a wrong guess is costly/irreversible; otherwise proceed).
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Runner/AskUserToolTests.cs` — answer path returns the answer; timeout path returns fallback (inject a short timeout or a seam) with a fake broadcaster + stub context accessor.
|
||||||
|
|
||||||
|
## Task 3 — Wire MCP for all runs + timeout env (worker)
|
||||||
|
- `TaskRunner.RunAsync`: move MCP-identity setup out of the `standalone` gate so every run gets `claudedo_run`; `AllowedTools` = `mcp__claudedo_run__AskUser` always, append `,mcp__claudedo_run__SuggestImprovement` when standalone. Keep token cleanup in `finally`.
|
||||||
|
- `ClaudeProcess.cs`: `psi.Environment["MCP_TOOL_TIMEOUT"] = "200000";`.
|
||||||
|
- System prompt file (PromptKind.System default): add one guidance line about `AskUser`.
|
||||||
|
|
||||||
|
## Task 4 — Hub + Broadcaster (worker)
|
||||||
|
- `HubBroadcaster.cs`: `TaskQuestionAsked(taskId, questionId, question)`, `TaskQuestionResolved(taskId, questionId)`.
|
||||||
|
- `WorkerHub.cs`: inject registry; `bool AnswerTaskQuestion(taskId, questionId, answer)`; `PendingQuestionDto? GetPendingQuestion(taskId)`; `record PendingQuestionDto(...)`.
|
||||||
|
- `Program.cs`: register `PendingQuestionRegistry` as singleton.
|
||||||
|
|
||||||
|
## Task 5 — UI client (IWorkerClient/WorkerClient + fakes)
|
||||||
|
- `IWorkerClient`: `Task AnswerTaskQuestionAsync(taskId, questionId, answer)`, `Task<PendingQuestionDto?> GetPendingQuestionAsync(taskId)`, events `Action<string,string,string>? TaskQuestionAskedEvent`, `Action<string,string>? TaskQuestionResolvedEvent`; UI DTO record.
|
||||||
|
- `WorkerClient`: implement invokes + `On<...>` handlers raising the events.
|
||||||
|
- Update hand-rolled `IWorkerClient` fakes in Ui.Tests (and Worker.Tests if present).
|
||||||
|
|
||||||
|
## Task 6 — TaskMonitorViewModel (hot file)
|
||||||
|
- Subscribe both events (filter by `_subscribedTaskId`); dispose handlers.
|
||||||
|
- Props: `PendingQuestionId`, `PendingQuestion`, `HasPendingQuestion`, `AnswerDraft`, `IsWaitingForInput`.
|
||||||
|
- `SubmitAnswerCommand` (CanExecute: non-empty draft + HasPendingQuestion) → `AnswerTaskQuestionAsync`; clear draft.
|
||||||
|
- Clear pending on `TaskFinished` for this task and in `Reset()`.
|
||||||
|
- Test: `TaskMonitorViewModelTests` — asked event surfaces question; submit invokes client + clears; resolved/finished clears.
|
||||||
|
|
||||||
|
## Task 7 — Hydrate on attach (MissionControlViewModel)
|
||||||
|
- In `HydrateAsync`, after `ApplyState`, call `GetPendingQuestionAsync(taskId)`; if present, set the monitor's pending question (re-attach case).
|
||||||
|
|
||||||
|
## Task 8 — View banner (hot file, additive)
|
||||||
|
- `MonitorPaneView.axaml`: a `Border DockPanel.Dock="Top"` above `SessionTerminalView`, `IsVisible="{Binding HasPendingQuestion}"`, showing the question text, a `TextBox` bound to `AnswerDraft` (Enter submits), and a Send `Button` → `SubmitAnswerCommand`. Mirror the roadblock-banner styling.
|
||||||
|
|
||||||
|
## Task 9 — Localization
|
||||||
|
- `en.json` + `de.json`: `missionControl.question.title`, `.placeholder`, `.send`. Keep parity (Localization.Tests).
|
||||||
|
|
||||||
|
## Task 10 — Build + test + verify
|
||||||
|
- `dotnet build` App + Worker `-c Release`; run Worker.Tests, Ui.Tests, Localization.Tests.
|
||||||
|
- Self-review diffs. Flag the two manual verification gaps to Mika. Do not push.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Plan — Mission Control (multi-task live monitoring)
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-25-mission-control-design.md`
|
||||||
|
|
||||||
|
Execution: subagent-driven, **sonnet** model, TDD where a test is meaningful, build + test before
|
||||||
|
each commit, one Conventional Commit per task. Stage files explicitly by path (never `git add -A`).
|
||||||
|
**No duplication** — every task reuses the assets named in the spec's reuse map.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Extract the reusable monitor core (no behavior change)
|
||||||
|
|
||||||
|
### Task 1.1 — Move `LogLineViewModel` + `LogKind` to their own file
|
||||||
|
- Cut `LogKind` enum and `LogLineViewModel` from `DetailsIslandViewModel.cs` into
|
||||||
|
`ViewModels/Islands/LogLineViewModel.cs` (same namespace). No logic change.
|
||||||
|
- Build `ClaudeDo.App`; run Ui.Tests. Commit: `refactor(ui): split LogLineViewModel into own file`.
|
||||||
|
|
||||||
|
### Task 1.2 — Create `TaskMonitorViewModel` owning the streaming/status/outcome core
|
||||||
|
- New `ViewModels/Islands/TaskMonitorViewModel.cs`. Move from `DetailsIslandViewModel`:
|
||||||
|
`Log`, `_subscribedTaskId`, `_formatter`, `_claudeBuf`, `OnTaskMessage`, `AppendStdoutLine`,
|
||||||
|
`FlushClaudeBuffer`, `ReplayLogFileAsync`, `ExpandUserPath`; `AgentState` + all `Is*` flags +
|
||||||
|
`OnAgentStateChanged`; `StatusToStateKey` / `FinishedStatusToStateKey`; `SessionOutcome` /
|
||||||
|
`Roadblocks` + `ApplyOutcome` + `RoadblockMarker`; the worker `TaskMessage/Started/Finished/Updated`
|
||||||
|
subscriptions for the streaming concern; `Title`/`TaskIdBadge`/`Model`/`TurnsText`/`TokensFormatted`/
|
||||||
|
diff text/elapsed; `BlockingReason` (+visible flag) from `BlockedByTaskId`/review/children/roadblocks.
|
||||||
|
- Ctor takes `IDbContextFactory<ClaudeDoDbContext>`, `IWorkerClient`. `Attach(taskId)` /
|
||||||
|
`AttachAsync(entity)` to (re)bind + replay; `IDisposable` unsubscribes (mirror existing Dispose).
|
||||||
|
- Unit test (Ui.Tests): feed `[stdout]`/`[claude]`/`[tool]` lines via the worker fake → `Log`
|
||||||
|
accumulates correctly; `TaskFinished` flips `AgentState`; `ApplyOutcome` splits the roadblock marker.
|
||||||
|
Reuse the existing IWorkerClient fake (see `iworkerclient_fakes_sync`).
|
||||||
|
- Build + test. Commit: `feat(ui): extract TaskMonitorViewModel streaming core`.
|
||||||
|
|
||||||
|
### Task 1.3 — `DetailsIslandViewModel` delegates to `Monitor`
|
||||||
|
- Add `public TaskMonitorViewModel Monitor { get; }`; construct it; route `Bind`/`BindAsync` to
|
||||||
|
`Monitor.Attach`. Remove the moved members; keep subtasks/attachments/editing/merge/review/child
|
||||||
|
outcomes/notes/prep intact. Dispose `Monitor`.
|
||||||
|
- Repoint `WorkConsole.axaml` Output-tab bindings (`Log`, `IsRunning/IsDone/IsFailed`,
|
||||||
|
`SessionOutcome`, `TurnsText`, `DiffAddText`/`DiffDelText`, `Model`) to `Monitor.*`. Leave
|
||||||
|
review/merge/session bindings unchanged.
|
||||||
|
- Build + test. **Manual visual pass: Details pane behaves exactly as before** (flag for Mika).
|
||||||
|
Commit: `refactor(ui): route DetailsIsland streaming through Monitor`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Mission Control window
|
||||||
|
|
||||||
|
### Task 2.1 — `MissionControlViewModel`
|
||||||
|
- New `ViewModels/MissionControlViewModel.cs`: `ObservableCollection<TaskMonitorViewModel> Monitors`
|
||||||
|
keyed by id; seed from `GetActive()`; add on `TaskStarted`, flip-state-and-keep on `TaskFinished`;
|
||||||
|
`ClearFinished` command; `ColumnCount`/layout signal from `Monitors.Count`; least-active collapse.
|
||||||
|
`IDisposable` disposes all monitors. Inject `IDbContextFactory`, `IWorkerClient`, `IServiceProvider`.
|
||||||
|
- Register `AddSingleton<MissionControlViewModel>` in `App/Program.cs`.
|
||||||
|
- Unit test: simulate two `TaskStarted` → two monitors; `TaskFinished` keeps the pane; `ColumnCount`
|
||||||
|
matches count. Commit: `feat(ui): add MissionControlViewModel`.
|
||||||
|
|
||||||
|
### Task 2.2 — `RevealTaskAsync` navigation on the shell
|
||||||
|
- Add `IslandsShellViewModel.RevealTaskAsync(taskId)` (resolve list → select → await load → select row).
|
||||||
|
- Wire `TaskMonitorViewModel.OpenInApp` to it (via an `Action<string>?` set by the shell, like the
|
||||||
|
existing `CloseDetail`/`DeleteFromList` hooks — no new DI cycle).
|
||||||
|
- Unit test for the select-by-id path. Commit: `feat(ui): reveal a task by id from anywhere`.
|
||||||
|
|
||||||
|
### Task 2.3 — `MonitorPaneView` (reuses `SessionTerminalView`)
|
||||||
|
- New `Views/MissionControl/MonitorPaneView.axaml(.cs)`: header (title/chip/tok/turn/elapsed),
|
||||||
|
blocking banner (`live-chip`/`terminal`/error-tint classes from IslandStyles — reuse), body =
|
||||||
|
`<SessionTerminalView Entries="{Binding Log}" ... />`, footer (Open in app / Detach / Cancel).
|
||||||
|
`x:DataType=TaskMonitorViewModel`. No new console control. Add `missionControl.*` en+de keys.
|
||||||
|
- Build + Localization.Tests. Commit: `feat(ui): add MonitorPaneView`.
|
||||||
|
|
||||||
|
### Task 2.4 — `MissionControlView` grid + `MissionControlWindow`
|
||||||
|
- `MissionControlView.axaml`: `ItemsControl`/`UniformGrid` of `MonitorPaneView` driven by `ColumnCount`,
|
||||||
|
horizontal scroll fallback, header with `ClearFinished` (+ optional QuickAdd, deferrable).
|
||||||
|
- `MissionControlWindow.axaml(.cs)`: hosts the view; lazy-create + hide-on-close.
|
||||||
|
- Build. Commit: `feat(ui): add MissionControl window + grid`.
|
||||||
|
|
||||||
|
### Task 2.5 — Launch button + lifetime
|
||||||
|
- Title-bar toggle button in `MainWindow.axaml` → shell command that shows/focuses the window
|
||||||
|
(created lazily, owns the singleton VM).
|
||||||
|
- Set `desktop.ShutdownMode = OnMainWindowClose` in `App.OnFrameworkInitializationCompleted`.
|
||||||
|
- Build. **Manual visual pass** (flag for Mika): open with 2+ running tasks; main window still adds
|
||||||
|
tasks; blocking banner; Open-in-app. Commit: `feat(ui): open Mission Control from the title bar`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Per-pane detach (lowest priority)
|
||||||
|
|
||||||
|
### Task 3.1 — `TaskMonitorWindow` + detach/re-dock
|
||||||
|
- `Views/MissionControl/TaskMonitorWindow.axaml(.cs)` hosting `MonitorPaneView`; `Detach` removes the
|
||||||
|
monitor from the grid and shows it in the window (optional always-on-top); close re-docks.
|
||||||
|
- Build. Manual visual pass. Commit: `feat(ui): detach a monitor into its own window`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-cutting checklist (every task)
|
||||||
|
- Stage by explicit path; sonnet subagents; reuse per the spec's map — no new console/streaming/insert path.
|
||||||
|
- en.json + de.json parity for any new string (Localization.Tests).
|
||||||
|
- If `IWorkerClient`/ctor signatures change, update the hand-rolled fakes in **both** test projects.
|
||||||
|
- Build `ClaudeDo.App` (`-c Release` if Worker is running) before marking a task done.
|
||||||
|
- Never push without asking.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Plan — In-App Interactive Sessions
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-26-in-app-interactive-sessions-design.md`
|
||||||
|
|
||||||
|
Implement on the shared main tree. Commit explicit paths per task (never `git add -A`).
|
||||||
|
Build with `-c Release` (running Worker locks Debug). No real-Claude tests — fake the
|
||||||
|
process stream. Sonnet subagents. Autonomous `TaskRunner`/`ClaudeProcess` path stays untouched.
|
||||||
|
|
||||||
|
## Task 1 — StreamingClaudeSession (worker, new file)
|
||||||
|
- `Runner/StreamingClaudeSession.cs`: persistent `claude` process. Ctor takes resolved args,
|
||||||
|
working dir, seeded first prompt, a line callback, `WorkerConfig`. Reuse the
|
||||||
|
`ProcessStartInfo` shape + `MCP_TOOL_TIMEOUT="200000"` from `ClaudeProcess`.
|
||||||
|
- Keeps stdin open; sends the first prompt as a user-message JSON line (escape via
|
||||||
|
`JsonSerializer`).
|
||||||
|
- stdout/stderr read tasks → line callback; parse `result` events to track `IsTurnInFlight`.
|
||||||
|
- `SendUserMessageAsync(text, ct)` — enqueue/write a user-message JSON line; if
|
||||||
|
`IsTurnInFlight`, also `InterruptAsync`.
|
||||||
|
- `InterruptAsync(ct)` — write the control-protocol interrupt line; best-effort (swallow +
|
||||||
|
log on failure → queue fallback applies).
|
||||||
|
- `StopAsync` / `DisposeAsync` — close stdin, kill the tree, await exit.
|
||||||
|
- Injectable stream seam so a fake can drive it without a real `claude` binary.
|
||||||
|
- Test: `StreamingClaudeSessionTests` (fake stream) — first message emitted; `result` flips
|
||||||
|
`IsTurnInFlight` off; a sent message produces a second turn; mid-turn send calls interrupt
|
||||||
|
then delivers; interrupt throw → delivered at natural turn end; stop kills.
|
||||||
|
|
||||||
|
## Task 2 — LiveSessionRegistry (worker, new file)
|
||||||
|
- `Runner/LiveSessionRegistry.cs`: singleton; `Register(taskId, StreamingClaudeSession)`,
|
||||||
|
`bool TryGet(taskId, out session)`, `Unregister(taskId)`, `Task StopAsync(taskId)`.
|
||||||
|
- Test: register→get; unregister; second register stops+replaces; missing get returns false.
|
||||||
|
|
||||||
|
## Task 3 — InteractiveSessionService (worker, new file)
|
||||||
|
- `Planning/InteractiveSessionService.cs`: inject `IDbContextFactory`, `WorkerConfig`,
|
||||||
|
`ClaudeArgsBuilder` (or build args inline), `HubBroadcaster`, `LiveSessionRegistry`.
|
||||||
|
- `StartAsync(taskId, ct)`: resolve list working dir + seeded prompt (reuse the body of
|
||||||
|
`PlanningSessionManager.OpenInteractiveAsync` + `BuildInteractivePrompt`); build interactive
|
||||||
|
args (`--model PlanningAlias --permission-mode auto` + streaming flags); spawn the session
|
||||||
|
with a callback that does `HubBroadcaster.TaskMessage(taskId, "[stdout] " + line)`;
|
||||||
|
register; broadcast `InteractiveSessionStarted`. Reject if one is already live for the task.
|
||||||
|
- `SendAsync(taskId, text, ct)` → registry `TryGet` → `SendUserMessageAsync`.
|
||||||
|
- `StopAsync(taskId, ct)` → registry stop + `InteractiveSessionEnded`.
|
||||||
|
- Move `OpenInteractiveAsync`/`BuildInteractivePrompt` out of `PlanningSessionManager` if it
|
||||||
|
reads cleaner (or call into it). Remove the `InteractiveLaunchContext` terminal coupling.
|
||||||
|
- Test: `InteractiveSessionServiceTests` (fake session factory + fake broadcaster) — start
|
||||||
|
resolves dir, seeds prompt, registers, broadcasts started; missing working dir throws;
|
||||||
|
send routes; stop broadcasts ended.
|
||||||
|
|
||||||
|
## Task 4 — Remove terminal interactive path (worker)
|
||||||
|
- `Planning/Interfaces/ITerminalLauncher.cs` + `WindowsTerminalLauncher.cs`: delete
|
||||||
|
`LaunchInteractiveAsync`; remove `InteractiveLaunchContext` from `PlanningSessionContext.cs`.
|
||||||
|
Keep planning start/resume launches.
|
||||||
|
- Fix any references; ensure the planning launcher tests still build.
|
||||||
|
|
||||||
|
## Task 5 — Hub + Broadcaster + DI (worker)
|
||||||
|
- `Hub/WorkerHub.cs`: re-point `OpenInteractiveTerminalAsync` to
|
||||||
|
`InteractiveSessionService.StartAsync` (drop `_launcher.LaunchInteractiveAsync`); add
|
||||||
|
`Task SendInteractiveMessage(taskId, text)`, `Task StopInteractiveSession(taskId)`
|
||||||
|
(+ optional `InterruptInteractiveSession`).
|
||||||
|
- `Hub/HubBroadcaster.cs`: `InteractiveSessionStarted(taskId)`, `InteractiveSessionEnded(taskId)`.
|
||||||
|
- `Program.cs`: register `LiveSessionRegistry` + `InteractiveSessionService` singletons.
|
||||||
|
- Test: `WorkerHub` send routes to a fake service; start invokes the service.
|
||||||
|
|
||||||
|
## Task 6 — UI client + fakes (ui)
|
||||||
|
- `Services/Interfaces/IWorkerClient.cs` + `WorkerClient.cs`: `SendInteractiveMessageAsync(
|
||||||
|
taskId, text)`, `StopInteractiveSessionAsync(taskId)` (+ optional interrupt); events
|
||||||
|
`Action<string>? InteractiveSessionStartedEvent`, `InteractiveSessionEndedEvent` with
|
||||||
|
`On<...>` handlers. `OpenInteractiveTerminalAsync` keeps name/signature.
|
||||||
|
- Update hand-rolled `IWorkerClient` fakes in **both** Ui.Tests and Worker.Tests.
|
||||||
|
|
||||||
|
## Task 7 — StreamLineFormatter user bubble (ui)
|
||||||
|
- Render `type:"user"` NDJSON events as `LogKind.User` (add the kind if missing).
|
||||||
|
- Test: a `user` event yields a `LogKind.User` `LogLineViewModel` with the text.
|
||||||
|
|
||||||
|
## Task 8 — Shared composer state on the session VMs (ui, hot files)
|
||||||
|
- Add to `TaskMonitorViewModel` and `DetailsIslandViewModel` (factor a shared helper —
|
||||||
|
`InteractiveComposer` — to avoid duplication): `ComposerDraft`, `IsInteractiveLive`
|
||||||
|
(toggled by `InteractiveSessionStarted/Ended` for the subscribed task),
|
||||||
|
`SubmitComposerCommand` (CanExecute: non-empty draft && (`HasPendingQuestion` ||
|
||||||
|
`IsInteractiveLive`)). Route: pending question → existing `AnswerTaskQuestionAsync`; else →
|
||||||
|
`SendInteractiveMessageAsync`. Clear draft on submit; clear `IsInteractiveLive` on ended.
|
||||||
|
- `MissionControlViewModel`: `EnsureMonitor(taskId)` on `InteractiveSessionStarted`.
|
||||||
|
- Test: composer enabled while interactive-live; submit routes (chat vs answer) + clears;
|
||||||
|
ended clears live state.
|
||||||
|
|
||||||
|
## Task 9 — SessionTerminalView composer (ui)
|
||||||
|
- `Views/Islands/SessionTerminalView.axaml(.cs)`: optional composer docked bottom (styled
|
||||||
|
props `IsComposerVisible`, `ComposerText`, `SubmitCommand`, `ComposerPlaceholder`); TextBox
|
||||||
|
(Enter submits) + Send button. Reuse existing tokens (no inline values).
|
||||||
|
- Bind it in `MonitorPaneView.axaml` and `DetailsIslandView.axaml` to each VM's composer
|
||||||
|
state. Fold the existing AskUser banner into the composer's "answering" state if it reads
|
||||||
|
cleaner; otherwise leave the banner and add the composer below.
|
||||||
|
|
||||||
|
## Task 10 — Localization
|
||||||
|
- `en.json` + `de.json`: `interactive.composer.placeholder`, `.send`, `.stop`, plus any
|
||||||
|
"session ended" notice. Keep parity (Localization.Tests).
|
||||||
|
|
||||||
|
## Task 11 — Build + test + verify
|
||||||
|
- Build App + Worker `-c Release`; run Worker.Tests, Ui.Tests, Localization.Tests.
|
||||||
|
- Self-review diffs. **Manual smoke (real CLI) — flag to Mika:** (a) Run interactively opens
|
||||||
|
an in-app chat (no terminal) and streams; (b) sending a message mid-turn interrupts +
|
||||||
|
redirects; (c) stop kills the process; (d) session shows in both task detail and Mission
|
||||||
|
Control. Do not push.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Session Skills — Implementation Plan
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-07-03-session-skills-design.md`
|
||||||
|
Approach: subagent-driven TDD (sonnet), build + test + commit per task, stage files by
|
||||||
|
path (never `git add -A`).
|
||||||
|
|
||||||
|
**Pre-flight (do first, before building anything):** manual smoke test — drop a skill
|
||||||
|
into a scratch worktree's `.claude/skills/` and run `claude -p` to confirm cwd skills are
|
||||||
|
discovered in headless mode. The whole feature rests on this. If it fails, stop and
|
||||||
|
redesign around `CLAUDE_CONFIG_DIR`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 — Data layer: columns + registry table + migration
|
||||||
|
|
||||||
|
- Add nullable `SessionSkills` (string, JSON array) to `TaskEntity`, `ListConfigEntity`,
|
||||||
|
`AppSettingsEntity`; map `session_skills` columns in their `*Configuration.cs`.
|
||||||
|
- New `SessionSkillEntity` (`name` PK, `source_url`, `pinned_ref`, `subpath`,
|
||||||
|
`description`, `added_at`) — one row per skill; a multi-skill repo writes N rows sharing
|
||||||
|
`source_url`/`pinned_ref` — + configuration + `session_skills` table.
|
||||||
|
- New `SessionSkillRepository` (async, CancellationToken): `ListAsync`, `GetAsync(name)`,
|
||||||
|
`UpsertAsync`, `DeleteAsync(name)`, `DeleteBySourceAsync(url)`, `ListBySourceAsync(url)`.
|
||||||
|
- EF migration `AddSessionSkills` (columns + table).
|
||||||
|
- **Tests (Data.Tests):** repository CRUD on real SQLite; JSON column round-trips a
|
||||||
|
name list.
|
||||||
|
|
||||||
|
## Task 2 — Registry service (install / update / remove)
|
||||||
|
|
||||||
|
- `Skills/SessionSkillRegistry` + `Skills/Interfaces/ISessionSkillRegistry`,
|
||||||
|
`IRepoCloner` (clone abstraction so tests inject a local source dir).
|
||||||
|
- `GitRepoCloner` (production) does `git clone` + resolves HEAD SHA.
|
||||||
|
- Install: clone → **detect layout** (`skills/*/SKILL.md` bundle → each subskill; else
|
||||||
|
root `SKILL.md` → single; else reject) → per skill parse YAML frontmatter (`name`,
|
||||||
|
`description`), copy its dir **flat** to `~/.todo-app/session-skills/<name>/`, upsert a
|
||||||
|
row with `subpath`. Reject collision with a skill from a different source; reinstalling
|
||||||
|
the same source refreshes.
|
||||||
|
- Update(sourceUrl) / Remove(sourceUrl) per spec (act on all of a source's skills).
|
||||||
|
- **Tests (Worker.Tests):** install a **multi-skill** fixture (fake cloner, mirrors
|
||||||
|
ponytail's `skills/*/SKILL.md`) → N rows + N flat dirs; install a root-`SKILL.md`
|
||||||
|
fixture → 1 row; neither → rejected; cross-source name collision rejected;
|
||||||
|
remove-by-source deletes all its dirs + rows. **No real network / no real claude CLI.**
|
||||||
|
|
||||||
|
## Task 3 — Resolution: union into ClaudeRunConfig
|
||||||
|
|
||||||
|
- Add `IReadOnlyList<string> SkillNames` to `ClaudeRunConfig` (default empty).
|
||||||
|
- In `TaskRunner.ResolveConfigAsync`: parse each level's `session_skills`, union + dedup,
|
||||||
|
filter to registry-existing names (drop + log missing).
|
||||||
|
- **Tests (Worker.Tests):** union across the three levels; dedup; unknown name dropped.
|
||||||
|
|
||||||
|
## Task 4 — Seeder
|
||||||
|
|
||||||
|
- `Skills/SessionSkillSeeder` + interface. `SeedAsync(cwd, skillNames, isWorktree, ct)`:
|
||||||
|
copy each installed skill dir → `<cwd>/.claude/skills/<name>/`; if worktree, append
|
||||||
|
`/.claude/skills/<name>/` to `git rev-parse --git-path info/exclude` target if absent.
|
||||||
|
- Wire into `TaskRunner` after run-dir resolution, before `ClaudeProcess.RunAsync`
|
||||||
|
(both worktree and sandbox paths).
|
||||||
|
- **Tests (Worker.Tests):** seeds into real temp dir; idempotent re-seed; worktree
|
||||||
|
exclude line written once and not duplicated; seeded path is git-ignored (real git
|
||||||
|
temp repo → `git status` clean for the seeded dir).
|
||||||
|
|
||||||
|
## Task 5 — Hub + DTOs + client
|
||||||
|
|
||||||
|
- `WorkerHub`: `GetSessionSkills`, `InstallSessionSkill(url)`, `UpdateSessionSkill(name)`,
|
||||||
|
`RemoveSessionSkill(name)`.
|
||||||
|
- New `SessionSkillDto`; extend `AppSettingsDto`, `ListConfigDto`, `UpdateListConfigDto`,
|
||||||
|
`UpdateTaskAgentSettingsDto` with skill-name lists; map in the update handlers.
|
||||||
|
- `IWorkerClient` + `WorkerClient` additions.
|
||||||
|
- **Update hand-rolled fakes** in Worker.Tests + Ui.Tests (memory
|
||||||
|
`iworkerclient_fakes_sync`).
|
||||||
|
- **Tests:** hub method round-trip via existing hub test harness where present.
|
||||||
|
|
||||||
|
## Task 6 — UI: registry tab + selectors
|
||||||
|
|
||||||
|
- `SessionSkillsSettingsTabViewModel` + a **Skills** tab in `SettingsModalView.axaml`:
|
||||||
|
installed list, Add (URL), Update, Remove, status line. Mirror
|
||||||
|
`FilesSettingsTabViewModel`.
|
||||||
|
- Global multi-select in General settings tab → `AppSettings.SessionSkills`.
|
||||||
|
- Skills multi-select in shared `AgentConfigEditor` (covers List + Task) with inheritance
|
||||||
|
badge, wired through `AgentConfigEditorViewModel`.
|
||||||
|
- Localization: add EN + DE keys in parity (Localization.Tests enforces).
|
||||||
|
- **Tests (Ui.Tests / Localization.Tests):** VM load/save of selections; locale parity.
|
||||||
|
- **Visual verification is Mika's** — flag the gaps.
|
||||||
|
|
||||||
|
## Task 7 — Wiring, build, end-to-end smoke
|
||||||
|
|
||||||
|
- DI registration (registry, cloner, seeder) in `Program.cs`.
|
||||||
|
- Build all touched projects `-c Release`; run Worker/Data/Ui/Localization test projects.
|
||||||
|
- Manual E2E: install ponytail via the UI, enable per-task, run a task, confirm the skill
|
||||||
|
is available to the agent and **not** committed and **not** in interactive sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Commit per task with Conventional Commits (`feat(worker|ui|data): …`). Commit the
|
||||||
|
spec + plan docs first.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Plan — ConPTY Interactive Sessions
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-07-23-conpty-interactive-sessions-design.md`
|
||||||
|
Date: 2026-07-23
|
||||||
|
|
||||||
|
Execution: subagent-driven-development, sonnet model, TDD where meaningful,
|
||||||
|
build + test + commit per task. Stage files explicitly by path (never
|
||||||
|
`git add -A`). Terminal rendering is visual — flagged for the user's visual pass.
|
||||||
|
|
||||||
|
## Task 0 — Spike: embed a ConPTY terminal running `claude`
|
||||||
|
|
||||||
|
Not TDD; a throwaway proof. Add a temporary window/view that embeds each
|
||||||
|
candidate control and launches `claude` in a known worktree.
|
||||||
|
|
||||||
|
- Evaluate **SvcSystems.UI.Terminal** and **Iciclecreek.Avalonia.Terminal**.
|
||||||
|
- Acceptance: the real `claude` TUI renders correctly — colors, resize/reflow,
|
||||||
|
and a live permission prompt is usable; input reaches the CLI.
|
||||||
|
- Output: pick one library; note the control API (start with cwd/exe/args/env,
|
||||||
|
process-exited event, dispose/kill). Record the decision in the spec.
|
||||||
|
- Remove the throwaway harness before Task 1 (or keep as a manual dev sample,
|
||||||
|
not wired into the app).
|
||||||
|
|
||||||
|
**Stop for the user's visual verification of the spike before continuing.**
|
||||||
|
|
||||||
|
## Task 1 — Worker: interactive launch-spec endpoint
|
||||||
|
|
||||||
|
- Add a Worker service/hub method that, given a taskId, prepares the worktree
|
||||||
|
(session-skills seeding, agent files, MCP config, env — reuse the autonomous
|
||||||
|
run prep path) and returns a `LaunchSpec { cwd, exe, args, env }`.
|
||||||
|
- Reuse `WindowsTerminalLauncher.BuildResumeCommand` for exe/args.
|
||||||
|
- Guards mirror `ResumeTaskInTerminal` (not Running/Queued, persisted SessionId,
|
||||||
|
worktree Active/Kept). Never-run task → spec without `--resume` (fresh start).
|
||||||
|
- Tests (Worker.Tests, real SQLite/git): guard cases, spec contents for a
|
||||||
|
resumable task, fresh-start case. No real `claude` in tests.
|
||||||
|
|
||||||
|
## Task 2 — Worker: ad-hoc launch-spec
|
||||||
|
|
||||||
|
- Method to build a `LaunchSpec` for a free session in a given directory:
|
||||||
|
MCP config + env set up, no task/session-skills seeding.
|
||||||
|
- Tests: env/MCP presence, arbitrary cwd.
|
||||||
|
|
||||||
|
## Task 3 — UI: terminal host control + view model
|
||||||
|
|
||||||
|
- Wrap the chosen library in an app control/view (e.g. `InteractiveTerminalView`
|
||||||
|
+ `InteractiveTerminalViewModel`) that starts from a `LaunchSpec` and exposes
|
||||||
|
running/exited state.
|
||||||
|
- `IWorkerClient`: add methods to fetch the task and ad-hoc launch specs; wire
|
||||||
|
the SignalR client + hub method.
|
||||||
|
- Update hand-rolled `IWorkerClient`/hub fakes in BOTH test projects.
|
||||||
|
- Tests: view model starts/stops lifecycle with a fake terminal backend;
|
||||||
|
fake worker returns a spec.
|
||||||
|
|
||||||
|
## Task 4 — Command Center: host interactive panes + entry points
|
||||||
|
|
||||||
|
- `MonitorPaneView`: autonomous panes keep the streamed log; interactive panes
|
||||||
|
host the terminal control.
|
||||||
|
- Entry points: "Open interactive session" from a task (task-based) and a
|
||||||
|
"New session" action (ad-hoc, pick directory).
|
||||||
|
- Layout toggle: focus (tabs) ↔ overview (grid); reuse/extend the existing
|
||||||
|
`UniformGrid` column logic for the grid mode.
|
||||||
|
- Tests: view-model level (pane kind selection, layout toggle state). Rendering
|
||||||
|
is a visual-pass item.
|
||||||
|
|
||||||
|
## Task 5 — Remove the streaming interactive stack
|
||||||
|
|
||||||
|
Only after Tasks 1–4 land and the terminal path works.
|
||||||
|
|
||||||
|
- Worker: delete `StreamingClaudeSession`, `InteractiveSessionService`,
|
||||||
|
interactive `WorkerHub` methods + broadcast events, DI registrations.
|
||||||
|
Verify `LiveSessionRegistry` / `IdleSessionReaper` usage first; remove only if
|
||||||
|
unreferenced.
|
||||||
|
- UI: remove composer bits on `TaskMonitorViewModel`, the composer/queued portion
|
||||||
|
of `SessionTerminalView`, `IWorkerClient` interactive methods.
|
||||||
|
- Update fakes and delete now-dead tests. Full build + all test projects green.
|
||||||
|
|
||||||
|
## Task 6 — Docs
|
||||||
|
|
||||||
|
- Update `docs/open.md` with visual-verification items (spike render, terminal
|
||||||
|
resize/focus, grid vs tabs).
|
||||||
|
- Update affected per-project `CLAUDE.md` (Worker interactive removal, UI new
|
||||||
|
terminal host).
|
||||||
|
|
||||||
|
## Verification gates
|
||||||
|
|
||||||
|
- After Task 0: user visual pass on the spike.
|
||||||
|
- After Task 4: user visual pass on Command Center (task + ad-hoc, tabs + grid,
|
||||||
|
permission prompt round-trip).
|
||||||
|
- Never claim the terminal UI works without the user running it.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Merge Helper — Implementation Plan
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-07-24-merge-helper-design.md`
|
||||||
|
Approach: subagent-driven (one subagent per task, `sonnet`, TDD, stage files by path — never `git add -A`). Build with `-c Release` per-csproj (a running Worker locks `Debug`). Commit per task, Conventional Commits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A — Worker MCP conflict tools
|
||||||
|
|
||||||
|
Independently useful; merges first. All in `src/ClaudeDo.Worker/External/ExternalMcpService.cs` + tests in `tests/ClaudeDo.Worker.Tests/`.
|
||||||
|
|
||||||
|
### A1 — Verify engine surface (spike, no commit)
|
||||||
|
Read `TaskMergeService.MergeAsync` / `ContinueMergeAsync` / `AbortMergeAsync` and the hub conflict flow (`WorkerHub.StartConflictMerge`/`ContinueConflictMerge`/`AbortConflictMerge`). Pin down:
|
||||||
|
- exact `ContinueMergeAsync` / `AbortMergeAsync` signatures and how in-progress-merge state is located (repo + target branch from task/list, not shared hub state);
|
||||||
|
- how the childless approve path (`ApproveAndMergeAsync`) threads `leaveConflictsInTree`.
|
||||||
|
Record findings in the task notes; feeds A2/A3.
|
||||||
|
|
||||||
|
### A2 — `leaveConflictsInTree` on review_task / merge_task
|
||||||
|
- TDD: tests in `Worker.Tests` (real git) — clean merge → Done; conflict + flag → `conflict_in_tree`, markers present, task stays `WaitingForReview`, `repoPath` returned.
|
||||||
|
- Add optional param `leaveConflictsInTree = false` to `MergeTask` and `ReviewTask` (approve branch). When true, call the `leaveConflictsInTree:true` engine path and map the conflict result to `{ mergeStatus/merged, conflicts, repoPath }`.
|
||||||
|
- Keep default behaviour (abort-on-conflict) byte-identical when the flag is absent/false.
|
||||||
|
- Commit: `feat(worker): let review_task/merge_task leave conflicts in tree via MCP`
|
||||||
|
|
||||||
|
### A3 — `continue_merge` + `abort_merge` MCP tools
|
||||||
|
- TDD: continue after on-disk resolution → committed, task Done, worktree merged; continue with markers remaining → returns conflicts; abort → markers gone, task `WaitingForReview`; both on no-active-merge → clean MCP error; `TaskUpdated` fired.
|
||||||
|
- Add `[McpServerTool] continue_merge(taskId)` → `ContinueMergeAsync`; `abort_merge(taskId)` → `AbortMergeAsync`. Locate the merge from the task's repo/target. Emit `TaskUpdated`.
|
||||||
|
- **Route both single-task and orchestrated (parent/children) in-progress merges** where locatable from the task (per A1 findings): detect the kind and call the matching engine continue/abort (`TaskMergeService` vs `PlanningMergeOrchestrator.Continue/Abort`). If the orchestrated path can't be located without hub UI state, leave it to the manual fallback (documented in the B1 prompt) and note the gap in `docs/open.md`.
|
||||||
|
- Commit: `feat(worker): add continue_merge and abort_merge MCP tools`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase B — Worker launch for the merge-helper session
|
||||||
|
|
||||||
|
### B1 — Prompt templates
|
||||||
|
- Add `PromptKind.MergeHelper` + `PromptKind.MergeHelperInitial` to `ClaudeDo.Data/PromptFiles.cs` (file names `merge-helper-system.md` / `merge-helper-initial.md`, built-in `DefaultFor`, `Render` tokens for the initial brief).
|
||||||
|
- System prompt encodes §7 behaviour (per-status algorithm, ask-on-uncertainty, summary format). Merge-state rule: **prefer MCP tools whenever they apply**; hand-merge (Edit + `git commit -- <paths>`) is an accepted fallback only for merges the MCP tools can't reach (§5.3), never a shortcut around them.
|
||||||
|
- Initial brief renders a task table `{id,title,status,list,repo}` + scope label.
|
||||||
|
- TDD: `PromptFiles` tests — kinds resolve, defaults non-empty, `Render` substitutes brief tokens.
|
||||||
|
- Commit: `feat(data): add merge-helper prompt templates`
|
||||||
|
|
||||||
|
### B2 — `BuildForMergeHelper` launch spec
|
||||||
|
- TDD (`Worker.Tests`): distinct-repo `--add-dir` set computed from selected tasks; correct cwd per scope (per-list repo vs first repo global); brief file written to `~/.todo-app/merge-helper-sessions/<guid>/brief.md`; allowed-tools + `--permission-mode default` + `MCP_TOOL_TIMEOUT` env correct; single-line kickoff points at the brief.
|
||||||
|
- Implement `InteractiveLaunchSpecService.BuildForMergeHelper(IReadOnlyList<string> taskIds, MergeHelperScope scope, ct)`. Reuse the planning brief-file/kickoff pattern.
|
||||||
|
- Commit: `feat(worker): build merge-helper interactive launch spec`
|
||||||
|
|
||||||
|
### B3 — Hub endpoint + client method
|
||||||
|
- `WorkerHub.GetMergeHelperLaunchSpec(string[] taskIds, string? listId)`; `IWorkerClient.GetMergeHelperLaunchSpecAsync(...)` + `WorkerClient` impl.
|
||||||
|
- Update hand-rolled `IWorkerClient` fakes in **both** test projects (see gotcha memory).
|
||||||
|
- Commit: `feat(worker): expose merge-helper launch spec over the hub`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase C — UI
|
||||||
|
|
||||||
|
### C1 — Selection dialog (View + VM)
|
||||||
|
- New `MergeHelperSelectionViewModel` + `MergeHelperSelectionDialog.axaml` (compiled bindings, `TaskCompletionSource<T>` pattern). Checkbox rows (title, status badge, list/repo), grouping in global mode, default ticks per §4, select-all/none, confirm disabled when empty.
|
||||||
|
- Candidates via existing `list_tasks`/worker client; filter client-side.
|
||||||
|
- TDD (`Ui.Tests`): default-tick logic, empty→confirm-disabled, returns ordered selected IDs + list mapping.
|
||||||
|
- Commit: `feat(ui): add merge-helper task selection dialog`
|
||||||
|
|
||||||
|
### C2 — Entry points + event plumbing
|
||||||
|
- Per-list context-menu item **"Let Claude handle it"** in `ListsIslandView.axaml` (user-list rows) + one global entry in the footer. Bind to `LetClaudeHandleCommand` on `ListsIslandViewModel` (param = `ListNavItemViewModel` or a global sentinel).
|
||||||
|
- VM raises `LetClaudeHandleRequested(MergeHelperScope)`; `IslandsShellViewModel` forwards to Mission Control.
|
||||||
|
- Commit: `feat(ui): add "Let Claude handle it" entry points`
|
||||||
|
|
||||||
|
### C3 — Mission Control wiring
|
||||||
|
- `MissionControlViewModel.OpenMergeHelperConPtySessionAsync(scope)`: open selection dialog → on confirm, `GetMergeHelperLaunchSpecAsync` → wrap in `TerminalLaunchDescriptor` → new `ConPtyPaneViewModel` (never deduped) → add to `ConPtySessions`/`Panes`.
|
||||||
|
- Commit: `feat(ui): open merge-helper ConPTY tile from selection`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verify (per task + at the end)
|
||||||
|
- Read each subagent diff; build the touched csproj `-c Release`; run the relevant test project.
|
||||||
|
- `locales/en.json` + `de.json` parity for any new UI strings (Localization.Tests enforces it).
|
||||||
|
- Flag visual-verification gaps (dialog layout, tile) for the user — never claim UI works without a run.
|
||||||
|
- End-to-end ConPTY smoke (real Claude) is a manual item in `docs/open.md`.
|
||||||
|
|
||||||
|
## Commit docs first
|
||||||
|
`docs(merge-helper): spec + implementation plan` (this file + the spec).
|
||||||
@@ -0,0 +1,938 @@
|
|||||||
|
# Per-List Task Handler 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 "Let Claude handle it" list-scoped only, and turn its prompt into a five-phase run — read all tasks, dedupe, enhance, queue, review+merge.
|
||||||
|
|
||||||
|
**Architecture:** Four independent commits. Two touch only leaf code (the MCP status tool, the prompt templates). One strips the global UI entry point. The last is an atomic sweep that makes `listId` non-nullable end to end and collapses the launch spec to a single repo — atomic because a half-flipped signature chain leaves nullable warnings scattered across a commit boundary.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, xUnit, Avalonia 12, EF Core + SQLite, CommunityToolkit.Mvvm.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-27-list-handler-design.md`
|
||||||
|
|
||||||
|
**Build note:** `dotnet build ClaudeDo.slnx` needs .NET 9 — build individual csproj with `-c Release` (a running Worker locks `Debug` output).
|
||||||
|
|
||||||
|
**Staging note:** the checkout is shared with parallel sessions. Always `git add -- <exact paths>` and `git commit -- <exact paths>`. Never `git add -A`, never a bare `git commit`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `update_task_status` accepts `Cancelled`
|
||||||
|
|
||||||
|
Dedupe needs to retire an **Idle** duplicate. Today nothing can: `UpdateTaskStatus` allows only
|
||||||
|
`Idle`/`Queued`, `cancel_task` only cancels a *running* task, and `review_task(decision="cancel")`
|
||||||
|
requires WaitingForReview/Running/Queued. `TaskStateService.CancelAsync` already owns the
|
||||||
|
transition and its side effects.
|
||||||
|
|
||||||
|
`BatchMcpTools.BatchUpdateTaskStatus` delegates to this same method, so batch cancel comes free.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs:264-300`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append inside the `ExternalMcpServiceTests` class. `SeedTaskAsync` does not exist in this class —
|
||||||
|
seed inline the way the existing tests do, via `_lists` / `_tasks`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task<TaskEntity> SeedPlainTaskAsync(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 UpdateTaskStatus_Cancelled_CancelsAnIdleTask()
|
||||||
|
{
|
||||||
|
var task = await SeedPlainTaskAsync(TaskStatus.Idle);
|
||||||
|
var queue = CreateQueue();
|
||||||
|
var sut = BuildSut(queue);
|
||||||
|
|
||||||
|
var dto = await sut.UpdateTaskStatus(task.Id, "Cancelled", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("Cancelled", dto.Status);
|
||||||
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Cancelled, loaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateTaskStatus_Done_StillRejected()
|
||||||
|
{
|
||||||
|
var task = await SeedPlainTaskAsync(TaskStatus.Idle);
|
||||||
|
var queue = CreateQueue();
|
||||||
|
var sut = BuildSut(queue);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));
|
||||||
|
Assert.Contains("not settable externally", ex.Message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~ExternalMcpServiceTests.UpdateTaskStatus"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `UpdateTaskStatus_Cancelled_CancelsAnIdleTask` FAILS with
|
||||||
|
`Status 'Cancelled' is not settable externally.`; `UpdateTaskStatus_Done_StillRejected` passes.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the `Cancelled` branch**
|
||||||
|
|
||||||
|
In `ExternalMcpService.UpdateTaskStatus`, insert between the `Queued` case and `default`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
case TaskStatus.Cancelled:
|
||||||
|
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken);
|
||||||
|
if (!cancelResult.Ok)
|
||||||
|
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update the `[McpServerTool, Description(...)]` text directly above the method — it currently
|
||||||
|
claims only Idle and Queued are permitted. Replace the whole attribute with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Update a task's status. Only 'Idle', 'Queued' and 'Cancelled' are permitted externally — " +
|
||||||
|
"use run_task_now for execution control, and review_task to act on a WaitingForReview task. " +
|
||||||
|
"Settable: Idle (reset to editable), Queued (enqueue for execution), " +
|
||||||
|
"Cancelled (retire the task without deleting it; it can be reset to Idle later). " +
|
||||||
|
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")]
|
||||||
|
```
|
||||||
|
|
||||||
|
Also fix the `default` branch message, which still points at `cancel_task`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Status '{target}' is not settable externally. Use run_task_now or review_task.");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~ExternalMcpServiceTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the MCP schema test**
|
||||||
|
|
||||||
|
`ExternalMcpToolSchemaTests` asserts over tool descriptions and may pin the old text.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~ExternalMcpToolSchemaTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS. If it fails on the changed description, update the assertion to match the new
|
||||||
|
text — do not revert the description.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -- src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||||
|
git commit -m "feat(worker): allow update_task_status to set Cancelled" -- src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
(If Step 5 required a schema-test edit, add that path to both commands too.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Five-phase helper prompt
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/PromptFiles.cs:231-276` (`MergeHelperDefault`, `MergeHelperInitialDefault`)
|
||||||
|
- Test: `tests/ClaudeDo.Data.Tests/PromptFilesTests.cs:54-80`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Replace the existing `DefaultFor_merge_helper_is_non_empty_and_mentions_the_merge_tools` test with
|
||||||
|
the two below, and keep the other merge-helper tests as they are.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void DefaultFor_merge_helper_covers_all_five_phases()
|
||||||
|
{
|
||||||
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(d));
|
||||||
|
Assert.Contains("Phase 0", d);
|
||||||
|
Assert.Contains("Phase 1", d);
|
||||||
|
Assert.Contains("Phase 2", d);
|
||||||
|
Assert.Contains("Phase 3", d);
|
||||||
|
Assert.Contains("Phase 4", d);
|
||||||
|
Assert.Contains("Phase 5", d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultFor_merge_helper_names_the_tools_each_phase_needs()
|
||||||
|
{
|
||||||
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
|
||||||
|
Assert.Contains("batch_get_tasks", d); // phase 0
|
||||||
|
Assert.Contains("update_task", d); // phase 1 + 2
|
||||||
|
Assert.Contains("get_app_settings", d); // phase 3
|
||||||
|
Assert.Contains("update_task_status", d); // phase 3
|
||||||
|
Assert.Contains("review_task", d); // phase 4
|
||||||
|
Assert.Contains("continue_merge", d); // phase 4
|
||||||
|
Assert.DoesNotContain("run_task_now(", d); // single override slot — must not batch-start
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~PromptFilesTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both new tests FAIL (no "Phase 0", no `batch_get_tasks`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace `MergeHelperDefault`**
|
||||||
|
|
||||||
|
Replace the whole `private const string MergeHelperDefault = """ … """;` block with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private const string MergeHelperDefault = """
|
||||||
|
You are the ClaudeDo list handler, running as an interactive session with the user watching. Ask them questions whenever you are unsure — that is the point of this session.
|
||||||
|
|
||||||
|
Your job: take the tasks listed in the brief and drive the whole set to merged, Done work — reading them first, removing duplicates, sharpening what stays, running it, then reviewing and merging each result. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo.
|
||||||
|
|
||||||
|
Work the five phases in order. Do not start a phase before the previous one is finished.
|
||||||
|
|
||||||
|
## Phase 0 — Read everything
|
||||||
|
Call batch_get_tasks with every id from the brief and read each task's title, description, status and parent/child links. Do not act on any single task before you have read them all — Phase 1 needs the whole set in view.
|
||||||
|
|
||||||
|
## Phase 1 — Dedupe
|
||||||
|
Compare the tasks pairwise for overlap: same goal stated twice, one task fully contained in another, two tasks that would edit the same thing for the same reason.
|
||||||
|
|
||||||
|
Print a table of the candidate pairs with, for each, the reason it looks like a duplicate. Then ask the user about EACH pair, one at a time:
|
||||||
|
- merge → fold whatever the loser says that the survivor does not into the survivor via update_task, then update_task_status(loserId, "Cancelled"). Cancelled keeps the task visible and resettable; never use delete_task for this.
|
||||||
|
- keep both → note why and move on.
|
||||||
|
|
||||||
|
Cancel nothing without an explicit answer. If there are no duplicates, say so and go on.
|
||||||
|
|
||||||
|
## Phase 2 — Enhance for execution
|
||||||
|
Each surviving task is about to be run by an autonomous agent with no further input. Sharpen it so that run can succeed. For each task, rewrite title and description to carry:
|
||||||
|
- concrete acceptance criteria — what must be true when it is done,
|
||||||
|
- the files and areas actually involved, found with Read/Grep/Glob in the repo. Do not guess paths; look them up.
|
||||||
|
- what is explicitly out of scope.
|
||||||
|
|
||||||
|
Write it back with update_task (title, description and commitType are the settable fields).
|
||||||
|
|
||||||
|
Rules: do not change what the user asked for, and do not invent requirements. You are making the existing intent precise, not adding to it. If a task is too vague to sharpen without guessing, ASK instead of guessing. Report a short before/after per task.
|
||||||
|
|
||||||
|
## Phase 3 — Run
|
||||||
|
Do NOT use run_task_now for a batch — there is a single override slot and the second call fails with "override slot busy".
|
||||||
|
|
||||||
|
Read get_app_settings and tell the user how many parallel execution slots are configured (maxParallelExecutions). If it is 1, say plainly that the tasks will execute one after another and that the value is changeable in ClaudeDo's settings.
|
||||||
|
|
||||||
|
Then, for each surviving task:
|
||||||
|
- Idle or Failed → update_task_status(id, "Queued"). For a Failed task ask first whether to reset_failed_task and re-queue it, or skip it.
|
||||||
|
- Queued → leave it; it is already waiting for a slot.
|
||||||
|
- Running or WaitingForChildren → leave it; only poll.
|
||||||
|
- WaitingForReview → leave it; it goes straight to Phase 4.
|
||||||
|
|
||||||
|
Poll get_task until every task has left Queued and Running — WaitingForReview on success, Failed on error. Report progress as tasks land; do not poll silently for minutes.
|
||||||
|
|
||||||
|
## Phase 4 — Review and merge
|
||||||
|
One task at a time, in the order the brief lists them.
|
||||||
|
|
||||||
|
1. Inspect the change with get_task_diff (stat first, then the full diff if it is non-trivial) and sanity-check it against the task's title and description.
|
||||||
|
2. If the change looks wrong, incomplete, or risky, STOP and ask the user before merging — offer reject_rerun (with feedback) or skip.
|
||||||
|
3. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true).
|
||||||
|
- Clean merge → the task is Done; move on.
|
||||||
|
- Conflict (markers left in the working tree, repoPath returned) → resolve it.
|
||||||
|
|
||||||
|
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
|
||||||
|
|
||||||
|
Resolving a conflict:
|
||||||
|
- Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you — fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve.
|
||||||
|
- For a task WITH children (a unit merge), pass the PARENT task id to continue_merge / abort_merge.
|
||||||
|
- If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing.
|
||||||
|
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
|
||||||
|
|
||||||
|
Rules for the whole session:
|
||||||
|
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
|
||||||
|
- Ask the user for anything ambiguous, risky, or destructive.
|
||||||
|
|
||||||
|
## Phase 5 — Summary
|
||||||
|
Print one line per task from the original brief:
|
||||||
|
title — dedupe action (kept / merged into X / cancelled as duplicate of X) — enhanced (yes/no) — final status — merge commit (if any) — conflicts resolved (if any).
|
||||||
|
|
||||||
|
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
|
||||||
|
""";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace `MergeHelperInitialDefault`**
|
||||||
|
|
||||||
|
The scope is now always one list with one repo, so the header states it once and the task lines
|
||||||
|
drop the constant `list:` / `repo:` fields.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private const string MergeHelperInitialDefault = """
|
||||||
|
# List handler brief
|
||||||
|
|
||||||
|
Scope: {scope}
|
||||||
|
Repo: {repo}
|
||||||
|
|
||||||
|
Handle the following tasks. Work Phases 0–5 as your instructions describe, asking me whenever you are unsure.
|
||||||
|
|
||||||
|
{tasks}
|
||||||
|
|
||||||
|
When every task is handled, print the summary.
|
||||||
|
""";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the `{repo}` token test**
|
||||||
|
|
||||||
|
`{repo}` is a new token — Task 4 will pass it. Add to `PromptFilesTests`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void DefaultFor_merge_helper_initial_has_repo_token()
|
||||||
|
{
|
||||||
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperInitial);
|
||||||
|
Assert.Contains("{repo}", d);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `RenderTemplate_merge_helper_initial_substitutes_scope_and_tasks` test passes only
|
||||||
|
`scope` and `tasks`. `RenderTemplate` leaves unknown tokens alone, so its two `Assert.Contains`
|
||||||
|
still hold and its `Assert.DoesNotContain("{scope}", outp)` still holds. Leave it unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~PromptFilesTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -- src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs
|
||||||
|
git commit -m "feat(data): five-phase list-handler prompt with dedupe and enhance" -- src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Drop the global entry point and the LIST column
|
||||||
|
|
||||||
|
This removes every caller that passes a null `listId`, clearing the way for Task 4's signature
|
||||||
|
sweep. Types stay nullable here; only callers and UI go.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:103-113`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml:184,206-210`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs:14-53`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml:41-72`
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `src/ClaudeDo.Localization/locales/de.json`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the dialog tests to the list-only API**
|
||||||
|
|
||||||
|
`Configure` becomes `Configure(string listId, string listName)` and `IsGlobal` and `ListName` are
|
||||||
|
gone. Rewrite the affected tests. `Load_ExcludesTerminalStatuses_AndTicksActionableByDefault`,
|
||||||
|
`CanConfirm_FollowsRowSelection` and `Confirm_ReturnsSelectedIds_InRowOrder` all used
|
||||||
|
`Configure(null, null)` to see every seeded task — point them at `"L1"` instead, which holds all
|
||||||
|
eight seeded statuses (`t-other-list` lives in `L2` and drops out).
|
||||||
|
|
||||||
|
Replace the four tests below; leave `Load_NoCandidates_HasTasksFalse_CannotConfirm` untouched.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task Load_ExcludesTerminalStatuses_AndTicksActionableByDefault()
|
||||||
|
{
|
||||||
|
await SeedAllStatusesAsync();
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.Configure("L1", "Work");
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
Assert.DoesNotContain(vm.Tasks, t => t.Id is "t-done" or "t-cancelled");
|
||||||
|
Assert.DoesNotContain(vm.Tasks, t => t.Id == "t-other-list");
|
||||||
|
Assert.Equal(6, vm.Tasks.Count);
|
||||||
|
|
||||||
|
Assert.True(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected);
|
||||||
|
Assert.True(vm.Tasks.Single(t => t.Id == "t-queued").IsSelected);
|
||||||
|
Assert.True(vm.Tasks.Single(t => t.Id == "t-review").IsSelected);
|
||||||
|
Assert.True(vm.Tasks.Single(t => t.Id == "t-failed").IsSelected);
|
||||||
|
Assert.False(vm.Tasks.Single(t => t.Id == "t-running").IsSelected);
|
||||||
|
Assert.False(vm.Tasks.Single(t => t.Id == "t-children").IsSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Load_PerListScope_FiltersToThatList()
|
||||||
|
{
|
||||||
|
await SeedAllStatusesAsync();
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.Configure("L2", "Home");
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
Assert.Single(vm.Tasks);
|
||||||
|
Assert.Equal("t-other-list", vm.Tasks[0].Id);
|
||||||
|
Assert.Contains("Home", vm.ScopeLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CanConfirm_FollowsRowSelection()
|
||||||
|
{
|
||||||
|
await SeedAllStatusesAsync();
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.Configure("L1", "Work");
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
Assert.True(vm.CanConfirm);
|
||||||
|
|
||||||
|
vm.SelectNoneCommand.Execute(null);
|
||||||
|
Assert.False(vm.CanConfirm);
|
||||||
|
Assert.All(vm.Tasks, t => Assert.False(t.IsSelected));
|
||||||
|
|
||||||
|
vm.Tasks[0].IsSelected = true; // single row re-enables via PropertyChanged hook
|
||||||
|
Assert.True(vm.CanConfirm);
|
||||||
|
|
||||||
|
vm.SelectAllCommand.Execute(null);
|
||||||
|
Assert.All(vm.Tasks, t => Assert.True(t.IsSelected));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Confirm_ReturnsSelectedIds_InRowOrder()
|
||||||
|
{
|
||||||
|
await SeedAllStatusesAsync();
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.Configure("L1", "Work");
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
vm.SelectNoneCommand.Execute(null);
|
||||||
|
vm.Tasks.Single(t => t.Id == "t-review").IsSelected = true;
|
||||||
|
vm.Tasks.Single(t => t.Id == "t-idle").IsSelected = true;
|
||||||
|
|
||||||
|
var closed = false;
|
||||||
|
vm.CloseAction = () => closed = true;
|
||||||
|
vm.ConfirmCommand.Execute(null);
|
||||||
|
|
||||||
|
var result = await vm.Result.Task;
|
||||||
|
Assert.NotNull(result);
|
||||||
|
// Row order (SortOrder): t-idle was seeded before t-review.
|
||||||
|
Assert.Equal(new[] { "t-idle", "t-review" }, result);
|
||||||
|
Assert.True(closed);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also change `Cancel_ReturnsNull`'s `vm.Configure(null, null);` to `vm.Configure("L1", "Work");`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~MergeHelperSelectionModalViewModelTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — the project does not compile, because `Configure(string, string)` does not exist
|
||||||
|
yet and `IsGlobal` was removed from an assertion that still compiles against it. Compilation
|
||||||
|
failure is the expected "red" here.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Make the dialog VM list-only**
|
||||||
|
|
||||||
|
In `MergeHelperSelectionModalViewModel.cs`:
|
||||||
|
|
||||||
|
Remove the `ListName` property from `MergeHelperTaskRowViewModel`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Title { get; init; }
|
||||||
|
public required string StatusText { get; init; }
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isSelected;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the field to non-nullable, drop `IsGlobal`, and make `Configure` list-only:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private string _listId = "";
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty] private string _scopeLabel = "";
|
||||||
|
|
||||||
|
public bool HasTasks => Tasks.Count > 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public void Configure(string listId, string listName)
|
||||||
|
{
|
||||||
|
_listId = listId;
|
||||||
|
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `LoadAsync`, the list filter is now unconditional and `ListName` is no longer selected:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var candidates = await ctx.Tasks.AsNoTracking()
|
||||||
|
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled)
|
||||||
|
.Where(t => t.ListId == _listId)
|
||||||
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
|
.Select(t => new { t.Id, t.Title, t.Status })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var c in candidates)
|
||||||
|
{
|
||||||
|
var row = new MergeHelperTaskRowViewModel
|
||||||
|
{
|
||||||
|
Id = c.Id,
|
||||||
|
Title = c.Title,
|
||||||
|
StatusText = c.Status.ToString(),
|
||||||
|
IsSelected = IsTickedByDefault(c.Status),
|
||||||
|
};
|
||||||
|
row.PropertyChanged += OnRowChanged;
|
||||||
|
Tasks.Add(row);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Drop the LIST column from the dialog view**
|
||||||
|
|
||||||
|
In `MergeHelperSelectionModal.axaml`, change both `ColumnDefinitions="32,*,120,120"` (lines 41 and
|
||||||
|
57) to `ColumnDefinitions="32,*,120"`, and delete the two `Grid.Column="3"` elements — the header
|
||||||
|
`TextBlock` bound to `modals.mergeHelper.columnList` (lines 45-46) and the row `TextBlock` bound to
|
||||||
|
`ListName` (lines 68-71).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Remove the global command and the Broom button**
|
||||||
|
|
||||||
|
In `ListsIslandViewModel.cs`, delete the whole `LetClaudeHandleAllAsync` method including its
|
||||||
|
`[RelayCommand]` attribute (lines 103-113). Leave `LetClaudeHandleListAsync` and the
|
||||||
|
`MergeHelperRequest` record as they are — Task 4 changes those.
|
||||||
|
|
||||||
|
In `ListsIslandView.axaml`, revert the button row to two columns:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- New list + import row -->
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||||
|
```
|
||||||
|
|
||||||
|
and delete the whole `<Button Grid.Column="2" … LetClaudeHandleAllCommand … />` element
|
||||||
|
(lines 206-210) including its `<PathIcon>` child.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Remove the three dead localization keys**
|
||||||
|
|
||||||
|
Delete from **both** `src/ClaudeDo.Localization/locales/en.json` and
|
||||||
|
`src/ClaudeDo.Localization/locales/de.json`:
|
||||||
|
|
||||||
|
- `lists.letClaudeAllTip` (and the trailing comma on the preceding key, so the object stays valid JSON)
|
||||||
|
- `modals.mergeHelper.scopeAll`
|
||||||
|
- `modals.mergeHelper.columnList` (and the trailing comma on the preceding key)
|
||||||
|
|
||||||
|
Keep `lists.contextLetClaude` and `modals.mergeHelper.scopeList`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build and run the tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build succeeds, all tests pass. The localization parity test is the one that catches a
|
||||||
|
key removed from only one of the two JSON files.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -- src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs
|
||||||
|
git commit -m "refactor(ui): scope \"Let Claude handle it\" to a single list" -- src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Non-nullable `listId` and a single-repo launch spec
|
||||||
|
|
||||||
|
One atomic commit across Worker and Ui. Splitting it would leave one side passing `string?` into a
|
||||||
|
`string` parameter — nullable warnings strewn across a commit boundary, and a launch spec that
|
||||||
|
still carries a dead multi-repo path.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs:36`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:166-253`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:682-687`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:90`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs:525-526`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs:325-357`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:20`
|
||||||
|
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs:103`
|
||||||
|
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs:78`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs:363-472`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs:409-463`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite the launch-spec tests**
|
||||||
|
|
||||||
|
In `InteractiveLaunchSpecServiceTests.cs`, replace the four merge-helper `[Fact]`s (from
|
||||||
|
`BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation` through
|
||||||
|
`BuildForMergeHelperAsync_WithListId_UsesListWorkingDirAsCwdAndListScope`) with these five. Keep
|
||||||
|
the `_mergeHelperSessionDirs` field and `TrackSessionDir` helper above them exactly as they are.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync(workingDir: _tempDir);
|
||||||
|
var svc = BuildService();
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
|
||||||
|
var taskId = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, CancellationToken.None));
|
||||||
|
Assert.Contains("working directory", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_UnknownList_Throws()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync(workingDir: _tempDir);
|
||||||
|
var taskId = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(
|
||||||
|
() => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_BuildsListScopedSpecWithSingleRepo()
|
||||||
|
{
|
||||||
|
var repo = Path.Combine(_tempDir, "repoOnly");
|
||||||
|
Directory.CreateDirectory(repo);
|
||||||
|
|
||||||
|
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
|
||||||
|
var t1 = Guid.NewGuid().ToString();
|
||||||
|
var t2 = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
|
||||||
|
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
|
||||||
|
var sessionDir = TrackSessionDir(spec);
|
||||||
|
|
||||||
|
Assert.Equal(repo, spec.Cwd);
|
||||||
|
Assert.Equal(_claudeStubPath, spec.Exe);
|
||||||
|
|
||||||
|
var args = spec.Args.ToList();
|
||||||
|
|
||||||
|
var pmIdx = args.IndexOf("--permission-mode");
|
||||||
|
Assert.True(pmIdx >= 0);
|
||||||
|
Assert.Equal("default", args[pmIdx + 1]);
|
||||||
|
|
||||||
|
var atIdx = args.IndexOf("--allowedTools");
|
||||||
|
Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]);
|
||||||
|
|
||||||
|
// --add-dir: session dir + the list's single repo dir
|
||||||
|
var addIdx = args.IndexOf("--add-dir");
|
||||||
|
var appendIdx = args.IndexOf("--append-system-prompt-file");
|
||||||
|
var addDirs = args.GetRange(addIdx + 1, appendIdx - addIdx - 1);
|
||||||
|
Assert.Equal(new[] { sessionDir, repo }, addDirs);
|
||||||
|
|
||||||
|
var systemPromptPath = args[appendIdx + 1];
|
||||||
|
Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
|
||||||
|
Assert.True(File.Exists(systemPromptPath));
|
||||||
|
|
||||||
|
// kickoff is the LAST arg (positional), single line, points at brief.md
|
||||||
|
var kickoff = args[^1];
|
||||||
|
var briefPath = Path.Combine(sessionDir, "brief.md");
|
||||||
|
Assert.Contains(briefPath, kickoff);
|
||||||
|
Assert.DoesNotContain('\n', kickoff);
|
||||||
|
|
||||||
|
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask()
|
||||||
|
{
|
||||||
|
var repo = Path.Combine(_tempDir, "repoBrief");
|
||||||
|
Directory.CreateDirectory(repo);
|
||||||
|
|
||||||
|
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
|
||||||
|
var t1 = Guid.NewGuid().ToString();
|
||||||
|
var t2 = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
|
||||||
|
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
|
||||||
|
var sessionDir = TrackSessionDir(spec);
|
||||||
|
|
||||||
|
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
|
||||||
|
Assert.Contains("Scope: List: Alpha", brief);
|
||||||
|
Assert.Contains($"Repo: {repo}", brief);
|
||||||
|
Assert.Contains("First task", brief);
|
||||||
|
Assert.Contains("Second task", brief);
|
||||||
|
Assert.Contains(t1, brief);
|
||||||
|
Assert.Contains(t2, brief);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the launch-spec tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~InteractiveLaunchSpecServiceTests.BuildForMergeHelper"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL. `BuildForMergeHelperAsync_UnknownList_Throws` fails because the current code
|
||||||
|
resolves the repo from the tasks and never validates the list; the brief test fails on the missing
|
||||||
|
`Repo:` line.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite `BuildForMergeHelperAsync`**
|
||||||
|
|
||||||
|
Replace the method body (`InteractiveLaunchSpecService.cs:166-253`) with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (taskIds.Count == 0)
|
||||||
|
throw new InvalidOperationException("No tasks selected for the list handler.");
|
||||||
|
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var taskRepo = new TaskRepository(ctx);
|
||||||
|
var listRepo = new ListRepository(ctx);
|
||||||
|
|
||||||
|
var list = await listRepo.GetByIdAsync(listId, ct)
|
||||||
|
?? throw new KeyNotFoundException($"List not found: {listId}");
|
||||||
|
|
||||||
|
var repoDir = list.WorkingDir;
|
||||||
|
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
|
||||||
|
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
|
||||||
|
|
||||||
|
var briefLines = new List<string>();
|
||||||
|
foreach (var id in taskIds)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct)
|
||||||
|
?? throw new KeyNotFoundException($"Task not found: {id}");
|
||||||
|
briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
|
||||||
|
Directory.CreateDirectory(sessionDir);
|
||||||
|
|
||||||
|
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
|
||||||
|
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelper), ct);
|
||||||
|
|
||||||
|
var briefPath = Path.Combine(sessionDir, "brief.md");
|
||||||
|
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["scope"] = $"List: {list.Name}",
|
||||||
|
["repo"] = repoDir,
|
||||||
|
["tasks"] = string.Join("\n", briefLines),
|
||||||
|
}), ct);
|
||||||
|
|
||||||
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||||
|
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
||||||
|
|
||||||
|
// Mirrors WindowsTerminalLauncher.BuildPlanningStartArgs ordering: variadic flags
|
||||||
|
// (--allowedTools, --add-dir) first, then a single-value flag, then the single-line
|
||||||
|
// positional kickoff LAST — a multi-line positional prompt truncates at the first
|
||||||
|
// newline, so the full multi-line brief travels via the file exposed through --add-dir.
|
||||||
|
var args = new List<string>
|
||||||
|
{
|
||||||
|
"--permission-mode", "default",
|
||||||
|
"--allowedTools", MergeHelperAllowedTools,
|
||||||
|
"--add-dir", sessionDir, repoDir,
|
||||||
|
"--append-system-prompt-file", systemPromptPath,
|
||||||
|
$"Read the file {briefPath} first. It lists the tasks you must handle and their status. " +
|
||||||
|
"After reading it, begin the session as your instructions describe.",
|
||||||
|
};
|
||||||
|
|
||||||
|
var env = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["MCP_TOOL_TIMEOUT"] = "200000",
|
||||||
|
};
|
||||||
|
|
||||||
|
return new LaunchSpec(cwd: repoDir, resolvedClaude, args, env);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Flip the interface and hub signatures**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs:36`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/ClaudeDo.Worker/Hub/WorkerHub.cs:682`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId) => HubGuard(() =>
|
||||||
|
```
|
||||||
|
|
||||||
|
(leave the method body as it is).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the Worker tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~InteractiveLaunchSpecServiceTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build succeeds, all pass. `TasksIslandViewModelPlanningTests.cs:78` holds a fake
|
||||||
|
implementing `IWorkerClient` — its signature is flipped in Step 7; if the Worker.Tests build fails
|
||||||
|
there, do Step 7 first and re-run.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update the Mission Control tests**
|
||||||
|
|
||||||
|
In `MissionControlViewModelTests.cs`, the four `OpenMergeHelperConPtySessionAsync` calls pass
|
||||||
|
`null` as the list id. Replace `null` with `"L1"` on lines 421, 435, 436 and 461, and change the
|
||||||
|
`ThrowingMergeHelperLaunchSpecWorker` override signature at line 411 to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
||||||
|
```
|
||||||
|
|
||||||
|
The list-title lookup in `OpenMergeHelperConPtySessionAsync` is wrapped in a `try/catch` and falls
|
||||||
|
back to the plain title, so an unseeded `"L1"` is harmless.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Flip the UI signatures**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:90`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default);
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/Services/WorkerClient.cs:525`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
||||||
|
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs:103` and
|
||||||
|
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs:78` — same parameter change
|
||||||
|
(`string? listId` → `string listId`), bodies unchanged.
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:20`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>Confirmed handler run: the scope list and the ordered selected task ids.</summary>
|
||||||
|
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds);
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs:325-341` — the `listId is not null` guard is
|
||||||
|
now dead:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// List-handler session over a hand-picked set of tasks ("Let Claude handle it").
|
||||||
|
// Ad-hoc style: no owning task, never deduped — every run opens a fresh pane.
|
||||||
|
public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
|
||||||
|
{
|
||||||
|
if (taskIds is not { Count: > 0 }) return;
|
||||||
|
|
||||||
|
var title = Loc.T("missionControl.mergeHelperTitle");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
|
||||||
|
if (list?.Name is { Length: > 0 } name) title = $"{title} — {name}";
|
||||||
|
}
|
||||||
|
catch { /* best-effort title lookup */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave the rest of the method (the `try` block that fetches the spec and adds the pane) unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Build everything and run the full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both builds succeed with no `CS8600`/`CS8604` nullability warnings on the touched files,
|
||||||
|
and every test passes.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
|
||||||
|
git commit -m "refactor(worker): make the list-handler launch spec single-list and single-repo" -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update the project docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
|
||||||
|
- Modify: `docs/open.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Check what the CLAUDE.md files claim**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "merge.helper\|Let Claude handle\|MergeHelper" src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Correct any stale claim**
|
||||||
|
|
||||||
|
Where those files describe the merge helper as having a global scope, or describe the prompt as
|
||||||
|
run-and-merge only, update them to: list-scoped only, single repo, five phases (read, dedupe,
|
||||||
|
enhance, queue, review+merge). Note in `src/ClaudeDo.Worker/CLAUDE.md` that
|
||||||
|
`update_task_status` now also accepts `Cancelled`. Do not restructure the files beyond that.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the open verification items**
|
||||||
|
|
||||||
|
Append to the open-items section of `docs/open.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer,
|
||||||
|
the context-menu item appears only on lists with a working dir, and the selection dialog has no
|
||||||
|
LIST column. Plus a real-Claude smoke run of the five phases (dedupe questions, enhancements
|
||||||
|
landing in task descriptions, queued execution, merges).
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -- src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
|
||||||
|
git commit -m "docs: describe the list-scoped five-phase handler" -- src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification left to the user
|
||||||
|
|
||||||
|
None of this can be confirmed from tests alone:
|
||||||
|
|
||||||
|
- The lists footer no longer shows the Broom button, and the row context menu still offers
|
||||||
|
"Let Claude handle it" for lists with a working dir.
|
||||||
|
- The selection dialog shows TASK and STATUS only, and the scope line reads `List: <name>`.
|
||||||
|
- A real ConPTY run: Phase 1 asks about duplicates, Phase 2's enhancements are visible in the task
|
||||||
|
descriptions afterwards, Phase 3 reports the slot count and the tasks execute, Phase 4 merges or
|
||||||
|
hands off to conflict resolution.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Waiting for Review — Task State — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-01
|
||||||
|
**Status:** Approved (brainstorming)
|
||||||
|
**Scope:** `ClaudeDo.Data` (TaskEntity, EF config + migration), `ClaudeDo.Worker` (TaskStateService, TaskRunner, QueueService, WorkerHub, ExternalMcpService), `ClaudeDo.Ui` (StatusColorConverter, TaskRowViewModel, views), CLAUDE.md docs
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
A successful task run currently transitions straight to `Done` and is considered complete. There is no gate for a human (or another agent) to review the result before it is accepted. We want review to be a mandatory step: after a successful run a task waits for an explicit approval, and a reviewer can send it back with feedback for another turn.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Add a `WaitingForReview` lifecycle state that a task enters automatically after a **successful** run.
|
||||||
|
- Reviewer can **approve** (→ `Done`), **reject-and-re-run** (→ `Queued`, resuming the same Claude session with required feedback), **reject-and-park** (→ `Idle`), or **cancel** (→ `Cancelled`).
|
||||||
|
- Reject-and-re-run reuses the existing session-resume mechanism so the agent continues with full context.
|
||||||
|
- Both the desktop UI and the external MCP surface can perform review actions.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No change to the failure path: a **failed** run still goes straight to `Failed`, never to `WaitingForReview`.
|
||||||
|
- No change to planning-phase finalization. A planning parent that generates child tasks keeps its current behavior and does **not** route through review. Only ordinary executable runs (`Running` → success) are affected.
|
||||||
|
- No change to worktree state flow (`Active | Merged | Discarded | Kept`).
|
||||||
|
- No change to the in-run auto-retry-on-failure behavior; only the *final* successful completion routes to review.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. State machine
|
||||||
|
|
||||||
|
Changed/added transitions in **bold**:
|
||||||
|
|
||||||
|
| From | To | Trigger |
|
||||||
|
|---|---|---|
|
||||||
|
| Idle | Queued | enqueue (unchanged) |
|
||||||
|
| Queued | Running | queue picker claim (unchanged) |
|
||||||
|
| Running | **WaitingForReview** | **successful run (was → Done)** |
|
||||||
|
| Running | Failed | failed run (unchanged) |
|
||||||
|
| Running | Cancelled | cancel during run (unchanged) |
|
||||||
|
| **WaitingForReview** | **Done** | **approve** |
|
||||||
|
| **WaitingForReview** | **Queued** | **reject + required feedback → resume re-run** |
|
||||||
|
| **WaitingForReview** | **Idle** | **reject → park for manual edit** |
|
||||||
|
| **WaitingForReview** | **Cancelled** | **abandon an almost-done task** |
|
||||||
|
| Done \| Failed \| Cancelled | Idle | reset (unchanged) |
|
||||||
|
|
||||||
|
### 2. Data model
|
||||||
|
|
||||||
|
`ClaudeDo.Data`:
|
||||||
|
|
||||||
|
- `TaskStatus` enum (`Models/TaskEntity.cs`): add `WaitingForReview` after `Running`.
|
||||||
|
- EF string converter (`Configuration/TaskEntityConfiguration.cs`): map `WaitingForReview` ⇄ `"waiting_for_review"` (TEXT column, no schema constraint to change).
|
||||||
|
- New nullable column **`ReviewFeedback : string?`** on `TaskEntity`. Holds the reviewer's rejection comment until the re-run consumes it, then it is cleared. Persisted so it survives a worker restart and is visible to the UI.
|
||||||
|
- One EF migration: add the `review_feedback` column. No backfill — the new status value and column are only written going forward.
|
||||||
|
|
||||||
|
### 3. Worker — status transitions (`State/TaskStateService.cs`)
|
||||||
|
|
||||||
|
`TaskStateService` remains the sole owner of status writes. New/changed methods:
|
||||||
|
|
||||||
|
- `SubmitForReviewAsync(taskId)` — `Running` → `WaitingForReview`. Sets `FinishedAt` and `Result` exactly as `CompleteAsync` does today. Called by `TaskRunner` on success **instead of** `CompleteAsync`. (`CompleteAsync` is retained for the approve path.)
|
||||||
|
- `ApproveReviewAsync(taskId)` — `WaitingForReview` → `Done`.
|
||||||
|
- `RejectToQueueAsync(taskId, feedback)` — `WaitingForReview` → `Queued`. Rejects empty/whitespace feedback with a failed `TransitionResult`. Stores `feedback` in `ReviewFeedback`. Wakes the queue.
|
||||||
|
- `RejectToIdleAsync(taskId)` — `WaitingForReview` → `Idle`. Parks for manual editing; leaves `Result` intact, clears `ReviewFeedback`.
|
||||||
|
- `CancelAsync` — extend the allowed source states to include `WaitingForReview`.
|
||||||
|
|
||||||
|
Each transition broadcasts `TaskUpdated` as today. Invalid source states return a failed `TransitionResult` (no throw), matching existing convention.
|
||||||
|
|
||||||
|
### 4. Resume-aware re-run (`Queue/QueueService.cs`)
|
||||||
|
|
||||||
|
The queue picker still atomically claims a `Queued`, unblocked task (`UPDATE … SET status='running' … RETURNING *`). The `RETURNING` row already carries `ReviewFeedback`. After a successful claim, `QueueService` branches:
|
||||||
|
|
||||||
|
1. **`ReviewFeedback` set + latest run has a `SessionId`** → `TaskRunner.ContinueAsync(task, feedback)` — `--resume {sessionId}` with `feedback` as the next-turn prompt.
|
||||||
|
2. **`ReviewFeedback` set, no prior `SessionId`** (edge case) → `TaskRunner.RunAsync` with the feedback appended to the task prompt, so the comment is not lost.
|
||||||
|
3. **No `ReviewFeedback`** → normal `TaskRunner.RunAsync` (fresh session).
|
||||||
|
|
||||||
|
`ReviewFeedback` is cleared once consumed (single UPDATE), so a later re-run does not re-apply stale feedback.
|
||||||
|
|
||||||
|
### 5. External MCP surface (`External/ExternalMcpService.cs`)
|
||||||
|
|
||||||
|
- New tool **`review_task(taskId, decision, feedback?)`**, `decision ∈ {approve, reject_rerun, reject_park, cancel}`. `feedback` is required when `decision = reject_rerun` (validation error otherwise). Maps onto the `TaskStateService` methods in §3. This lets automation / other agents act as reviewers.
|
||||||
|
- `get_task_status_values` — add `WaitingForReview` with a description covering the four exit actions.
|
||||||
|
- `list_tasks` status-filter parsing and validation message — include `WaitingForReview`.
|
||||||
|
- `get_task` lifecycle description text — update to `Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled`.
|
||||||
|
- `update_task_status` stays restricted to `Idle` and `Queued`; all review decisions go through `review_task` (keeps the "set status freely" affordance and the review affordance distinct).
|
||||||
|
|
||||||
|
### 6. Worker hub (`Hub/WorkerHub.cs` + `Hub/HubBroadcaster.cs`)
|
||||||
|
|
||||||
|
New hub methods called by the UI, each delegating to `TaskStateService`:
|
||||||
|
|
||||||
|
- `ApproveReview(taskId)`
|
||||||
|
- `RejectReviewToQueue(taskId, feedback)`
|
||||||
|
- `RejectReviewToIdle(taskId)`
|
||||||
|
|
||||||
|
Cancel already exists. No new broadcast events — `TaskUpdated` covers it.
|
||||||
|
|
||||||
|
### 7. UI (`ClaudeDo.Ui`)
|
||||||
|
|
||||||
|
- `Converters/StatusColorConverter.cs`: add a `waiting_for_review` case. Snap to an existing color token from the scale; final visual pass is left to the user (per project convention — centralize/tokenize, user does the visual pass).
|
||||||
|
- `ViewModels/Islands/TaskRowViewModel.cs`: add `IsWaitingForReview` computed property and commands **Approve**, **RejectRerun**, **RejectPark**, **Cancel** (the last reuses the existing cancel command). Commands are enabled only when `Status == WaitingForReview`.
|
||||||
|
- Reject-Rerun opens a small flyout/dialog with a required multi-line feedback text box; on confirm it calls `RejectReviewToQueue(taskId, feedback)`.
|
||||||
|
- Wire the commands to the new SignalR client methods.
|
||||||
|
|
||||||
|
### 8. Docs
|
||||||
|
|
||||||
|
Update the status flow in:
|
||||||
|
|
||||||
|
- root `CLAUDE.md` — "Task status flow" line.
|
||||||
|
- `src/ClaudeDo.Data/CLAUDE.md` — TaskEntity status list.
|
||||||
|
- `src/ClaudeDo.Worker/CLAUDE.md` — status-model transition table.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`ClaudeDo.Worker.Tests` (real SQLite + real git, existing harness):
|
||||||
|
|
||||||
|
- `SubmitForReviewAsync`: a successful run lands in `WaitingForReview`, not `Done`.
|
||||||
|
- `ApproveReviewAsync`: `WaitingForReview` → `Done`.
|
||||||
|
- `RejectToQueueAsync`: empty feedback rejected; valid feedback stored in `ReviewFeedback` and status → `Queued`.
|
||||||
|
- `RejectToIdleAsync`: → `Idle`, `Result` preserved, `ReviewFeedback` cleared.
|
||||||
|
- `CancelAsync` from `WaitingForReview` → `Cancelled`.
|
||||||
|
- Invalid source states (e.g. approve from `Idle`) return a failed `TransitionResult`.
|
||||||
|
- Resume-aware re-run: a task with `ReviewFeedback` + a prior `SessionId`, when claimed, resumes the session with the feedback as the prompt and clears `ReviewFeedback`.
|
||||||
|
- `review_task` MCP tool: each decision maps to the correct transition; `reject_rerun` without feedback errors.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None outstanding. Planning-task exclusion (Non-Goals) is the one assumption to verify against the planning-finalization code path during implementation; if planning finalization shares `CompleteAsync`, route only the executable-run success site through `SubmitForReviewAsync`.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Prime: recurring weekday schedule
|
||||||
|
|
||||||
|
**Date:** 2026-06-02
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The Prime feature fires a single non-interactive "ping" prompt to warm up the
|
||||||
|
Claude usage window. Today a schedule is defined by a **date range**
|
||||||
|
(`StartDate`/`EndDate`) plus a `TimeOfDay` and a single `WorkdaysOnly` toggle.
|
||||||
|
This is awkward for the real use case: the user wants a *recurring* morning ping
|
||||||
|
on specific weekdays, not a bounded calendar window.
|
||||||
|
|
||||||
|
Desired behavior: pick the **days of the week** (e.g. Mon–Fri) and a **time**.
|
||||||
|
The schedule recurs forever. Whenever the worker is running and it is one of the
|
||||||
|
selected days, the ping fires at (or shortly after) the chosen time. Concretely:
|
||||||
|
the worker autostarts on login, detects it is an eligible day around the target
|
||||||
|
time, and fires the ping.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **Catch-up window:** unchanged. Keep the existing 30-minute catch-up — if the
|
||||||
|
worker boots within 30 min after the target time, the ping fires immediately;
|
||||||
|
otherwise it waits for the next eligible day. (User chose "keep current 30 min".)
|
||||||
|
- **Day picker UI:** seven compact **toggle buttons** in one row (Mo Tu We Th Fr
|
||||||
|
Sa Su), highlighted when selected — not labeled checkboxes.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Data model
|
||||||
|
|
||||||
|
`PrimeScheduleEntity` (`ClaudeDo.Data/Models`):
|
||||||
|
|
||||||
|
- **Remove:** `StartDate`, `EndDate`, `WorkdaysOnly`
|
||||||
|
- **Add:** `Days` — a `[Flags] enum PrimeDays` (`Monday=1, Tuesday=2, Wednesday=4,
|
||||||
|
Thursday=8, Friday=16, Saturday=32, Sunday=64`), stored as a single
|
||||||
|
`days_of_week INTEGER` column.
|
||||||
|
- **Keep:** `TimeOfDay`, `Enabled`, `LastRunAt`, `PromptOverride`, `CreatedAt`.
|
||||||
|
|
||||||
|
Rationale for a bitmask over a CSV string or 7 bool columns: one column, trivial
|
||||||
|
EF mapping (int), and a clean eligibility check.
|
||||||
|
|
||||||
|
`PrimeScheduleEntityConfiguration`: drop the `start_date`/`end_date`/
|
||||||
|
`workdays_only` property mappings; map `Days` to `days_of_week` (int, required,
|
||||||
|
default 31 = Mon–Fri).
|
||||||
|
|
||||||
|
### 2. Scheduling logic — `NextDueCalculator`
|
||||||
|
|
||||||
|
- Drop all `StartDate`/`EndDate` gating (the `EndDate < today` early-out, the
|
||||||
|
`StartDate > today` clamps, and the bounds check in `IsEligibleDay`).
|
||||||
|
- `IsEligibleDay(s, d)` becomes: does `s.Days` contain the flag for
|
||||||
|
`d.DayOfWeek`? (Map `System.DayOfWeek` → `PrimeDays`.)
|
||||||
|
- The existing forward search (loops up to 8 days ahead) now simply walks to the
|
||||||
|
next selected weekday.
|
||||||
|
- `alreadyFiredToday` (compares `LastRunAt`'s local date to today) is unchanged.
|
||||||
|
- The 30-min catch-up (`FireImmediately`) is unchanged.
|
||||||
|
- A schedule with `Days == 0` (none selected) is never eligible. UI validation
|
||||||
|
prevents saving that state.
|
||||||
|
|
||||||
|
### 3. UI — `SettingsModalView.axaml` + `PrimeScheduleRowViewModel`
|
||||||
|
|
||||||
|
Row template changes:
|
||||||
|
- **Remove** the `ThemedDatePicker` (range) and the single "Mon–Fri" checkbox.
|
||||||
|
- **Add** a horizontal row of 7 `ToggleButton`s (Mo Tu We Th Fr Sa Su), styled
|
||||||
|
to highlight when checked, bound to seven bool properties on the row VM.
|
||||||
|
- Keep the enabled checkbox, the time `TextBox`, the last-run label, and the
|
||||||
|
remove button.
|
||||||
|
|
||||||
|
`PrimeScheduleRowViewModel`:
|
||||||
|
- Replace `StartDate`/`EndDate`/`WorkdaysOnly` with seven `[ObservableProperty]`
|
||||||
|
bools: `Monday`…`Sunday`.
|
||||||
|
- Constructor decomposes `dto.Days` into the seven bools.
|
||||||
|
- `ToDto()` composes the seven bools back into the `Days` int.
|
||||||
|
|
||||||
|
`PrimeClaudeTabViewModel`:
|
||||||
|
- `AddSchedule` default: Mon–Fri selected, time 07:00, enabled.
|
||||||
|
- `Validate`: replace the `StartDate > EndDate` check with "at least one day must
|
||||||
|
be selected"; keep the time-range (00:00–23:59) check.
|
||||||
|
|
||||||
|
Update the explainer `TextBlock` text to describe weekday recurrence (keep the
|
||||||
|
"fires immediately if started within 30 minutes of the target time" note).
|
||||||
|
|
||||||
|
### 4. Migration
|
||||||
|
|
||||||
|
New EF Core migration in `ClaudeDo.Data/Migrations`:
|
||||||
|
- Add `days_of_week INTEGER NOT NULL DEFAULT 31`.
|
||||||
|
- Backfill from existing rows: `workdays_only = 1` → `31` (Mon–Fri),
|
||||||
|
`workdays_only = 0` → `127` (all 7 days).
|
||||||
|
- Drop `start_date`, `end_date`, `workdays_only`.
|
||||||
|
- Update the model snapshot.
|
||||||
|
|
||||||
|
### 5. DTOs
|
||||||
|
|
||||||
|
Both copies of `PrimeScheduleDto` (Worker `ClaudeDo.Worker.Prime` and UI
|
||||||
|
`ClaudeDo.Ui.Services`) are passed over SignalR and must stay structurally
|
||||||
|
compatible. In both: remove `StartDate`, `EndDate`, `WorkdaysOnly`; add a single
|
||||||
|
`int Days` field (serializes cleanly as JSON; avoids sharing the enum across
|
||||||
|
projects). `PrimeScheduler.ToDto` maps `entity.Days` → `(int)`.
|
||||||
|
|
||||||
|
`PrimeScheduleRepository`: update `UpsertAsync` (copy `Days` instead of the three
|
||||||
|
removed fields) and `ListAsync` ordering (order by `TimeOfDay` instead of
|
||||||
|
`StartDate`).
|
||||||
|
|
||||||
|
### 6. Tests
|
||||||
|
|
||||||
|
- `NextDueCalculatorTests` — rewrite cases around weekday sets (e.g. Mon–Fri
|
||||||
|
skips weekend; single-day schedule; catch-up still fires; already-fired-today
|
||||||
|
skips to next eligible day).
|
||||||
|
- `PrimeSchedulerTests` — update fixture DTOs to the new shape.
|
||||||
|
- `PrimeScheduleRepositoryTests` — update entity construction and assertions.
|
||||||
|
- `PrimeClaudeTabViewModelTests` — update for the day-bool VM and new validation.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Per-schedule catch-up tuning (rejected; fixed 30 min).
|
||||||
|
- Multiple times per day, timezones, or holiday calendars.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Daily Prep ("Prime Claude") — Design
|
||||||
|
|
||||||
|
Date: 2026-06-03
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Turn the existing Prime Time warm-up into a **daily preparation** ("Tagesvorbereitung").
|
||||||
|
At a scheduled time (or on demand), Claude reads the open tasks, estimates effort,
|
||||||
|
and selects a focused subset into the MyDay list — capped so it never moves
|
||||||
|
everything in. Claude does the reasoning itself (agentic), via the already-registered
|
||||||
|
ClaudeDo MCP. This replaces the current `"ping"` behavior entirely.
|
||||||
|
|
||||||
|
A later phase will feed external tickets (Jira, possibly a second system) into the
|
||||||
|
same candidate pool; that is out of scope for this spec.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Scheduled and manual ("Tag vorbereiten" button) daily prep.
|
||||||
|
- Claude picks a subset of open tasks into MyDay, ordered so related tasks sit together.
|
||||||
|
- Effort-aware selection, hard-capped at `X` open MyDay tasks.
|
||||||
|
- Keep existing MyDay tasks across re-runs; only top up to `X`.
|
||||||
|
- Candidates limited to tasks in repos that are **not** excluded from the weekly report.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- External ticket integration (Jira etc.) — future phase.
|
||||||
|
- Group labels/headers in the MyDay view — grouping is ordering-only via `SortOrder`.
|
||||||
|
- A user-editable prep prompt — the prompt is fixed, parameterized.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
| --- | --- |
|
||||||
|
| Who reasons | Agentic — Claude decides via MCP tools. |
|
||||||
|
| MyDay model | `TaskEntity.IsMyDay` flag (smart list `smart:my-day`). |
|
||||||
|
| Grouping | Ordering only via existing `SortOrder` (no new field, no migration for grouping). |
|
||||||
|
| Selection | Effort estimate, hard cap `X` tasks/day. |
|
||||||
|
| Candidates | `Status == Idle`, `BlockedByTaskId == null`, list `WorkingDir` not under `ReportExcludedPaths`. |
|
||||||
|
| Re-run | Keep existing MyDay tasks; top up to `X`. |
|
||||||
|
| Trigger | Existing Prime schedule **and** a manual button. |
|
||||||
|
| Ping | Removed — daily prep replaces it. |
|
||||||
|
| Prompt | Fixed, with injected parameters (`X`, today's date). |
|
||||||
|
| Tool access | Reuse the globally registered `claudedo` MCP — **no** separate `--mcp-config`. |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. MCP tools (extend `ExternalMcpService`, port 47822)
|
||||||
|
|
||||||
|
The worker already exposes `ExternalMcpService` as the `claudedo` MCP server. Add two tools;
|
||||||
|
they automatically surface as `mcp__claudedo__get_daily_prep_candidates` and
|
||||||
|
`mcp__claudedo__set_my_day`.
|
||||||
|
|
||||||
|
- **`get_daily_prep_candidates()`** → JSON containing:
|
||||||
|
- `candidates[]`: open, non-blocked tasks in non-excluded repos, each with
|
||||||
|
`id, title, description, listName, isStarred, scheduledFor, age` (age derived from `CreatedAt`).
|
||||||
|
- `currentMyDay[]`: currently-`IsMyDay` open tasks (so Claude sees remaining capacity).
|
||||||
|
- Filter: `Status == Idle` AND `BlockedByTaskId == null` AND the task's list `WorkingDir`
|
||||||
|
does not start with any prefix in `AppSettings.ReportExcludedPaths`
|
||||||
|
(default `["C:\\Private"]`; case-insensitive prefix match, same semantics as the weekly report).
|
||||||
|
|
||||||
|
- **`set_my_day(taskId, isMyDay, sortOrder?)`** →
|
||||||
|
- Sets `IsMyDay` and (optionally) `SortOrder` on the task via `TaskRepository`.
|
||||||
|
- Broadcasts `TaskUpdated` via `HubBroadcaster` so the UI updates live.
|
||||||
|
- **Cap-guard:** when `isMyDay == true`, count current open (`Idle`) tasks with
|
||||||
|
`IsMyDay == true`. If `count >= X`, reject with an error message
|
||||||
|
("MyDay limit {X} reached"). `isMyDay == false` is always allowed.
|
||||||
|
`X = AppSettings.DailyPrepMaxTasks`. This guarantees the "never move everything in"
|
||||||
|
invariant server-side, independent of Claude's behavior.
|
||||||
|
|
||||||
|
### 2. `DailyPrepRunner` (replaces ping logic)
|
||||||
|
|
||||||
|
Rename `IPrimeRunner`/`PrimeRunner` → `IDailyPrepRunner`/`DailyPrepRunner` (the `"ping"`
|
||||||
|
concept is gone). It:
|
||||||
|
|
||||||
|
- Loads `AppSettings` (`X = DailyPrepMaxTasks`).
|
||||||
|
- Builds the fixed prompt with injected parameters (`X`, today's date).
|
||||||
|
- Invokes `claude -p --output-format stream-json --verbose` with:
|
||||||
|
- `--permission-mode` set so the headless run won't block on permission prompts,
|
||||||
|
- `--allowedTools mcp__claudedo__get_daily_prep_candidates mcp__claudedo__set_my_day`,
|
||||||
|
- `--max-turns 30` (constant), timeout 5 min (constant; larger than the old 60s ping).
|
||||||
|
- **No `--mcp-config`** — relies on the globally registered `claudedo` MCP (the worker runs
|
||||||
|
as the user via the per-user logon Scheduled Task, so the headless run inherits the
|
||||||
|
user-scope registration and its auth).
|
||||||
|
- Returns an outcome (e.g. number of tasks added) for broadcasting.
|
||||||
|
|
||||||
|
### 3. Scheduler
|
||||||
|
|
||||||
|
`PrimeScheduler` is unchanged in structure — it now calls `IDailyPrepRunner` instead of the
|
||||||
|
ping runner. `NextDueCalculator` and the schedule model are untouched.
|
||||||
|
|
||||||
|
### 4. Manual trigger
|
||||||
|
|
||||||
|
- Worker hub method `RunDailyPrepNow()` invokes the same `DailyPrepRunner`.
|
||||||
|
- UI button **"Tag vorbereiten"** in the MyDay list header.
|
||||||
|
- **Single-flight guard:** if a prep run is already in progress, the trigger reports
|
||||||
|
"already running" and does not start a parallel run (applies to both schedule and button).
|
||||||
|
|
||||||
|
### 5. Parameter config
|
||||||
|
|
||||||
|
- New field **`DailyPrepMaxTasks`** (int, default `5`) on `AppSettingsEntity`.
|
||||||
|
- Plumbing: EF config + migration, `AppSettingsRepository`, `WorkerHub` AppSettings DTO,
|
||||||
|
UI DTO mirror + `WorkerClient`, and a numeric editor in the Prime Claude settings tab.
|
||||||
|
- `ReportExcludedPaths` is reused as-is (already on `AppSettings`).
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. Trigger (schedule due **or** button) → `DailyPrepRunner.RunAsync`.
|
||||||
|
2. Runner loads `AppSettings` (`X`), builds prompt, launches Claude.
|
||||||
|
3. Claude → `get_daily_prep_candidates` → DB query returns filtered candidates + current MyDay.
|
||||||
|
4. Claude estimates effort, tops up to **X total**, calls `set_my_day(id, true, sortOrder)`
|
||||||
|
for each chosen task (consecutive `sortOrder` for related tasks).
|
||||||
|
5. `ExternalMcpService` writes `IsMyDay`/`SortOrder`, broadcasts `TaskUpdated` → MyDay list
|
||||||
|
updates live.
|
||||||
|
6. Runner updates `LastRunAt`, broadcasts "prep done" (count added).
|
||||||
|
|
||||||
|
## Fixed Prompt (parameterized)
|
||||||
|
|
||||||
|
Content (parameters in `{}`):
|
||||||
|
|
||||||
|
> Du bereitest meinen Arbeitstag für **{today}** vor.
|
||||||
|
> 1. Rufe `get_daily_prep_candidates` auf.
|
||||||
|
> 2. Behalte bereits als MyDay markierte offene Tasks.
|
||||||
|
> 3. Fülle bis **maximal {X} offene Tasks gesamt** in MyDay auf — niemals mehr.
|
||||||
|
> 4. Schätze pro Task grob den Aufwand; wähle eine machbare Mischung (nicht nur Großbrocken).
|
||||||
|
> Priorisiere `isStarred`, fällige (`scheduledFor`) und ältere Tasks.
|
||||||
|
> 5. Lege thematisch verwandte Tasks durch aufeinanderfolgende `sortOrder`-Werte nebeneinander.
|
||||||
|
> 6. Setze die Auswahl via `set_my_day(id, true, sortOrder)`. Markiere nichts außerhalb der
|
||||||
|
> Kandidatenliste.
|
||||||
|
|
||||||
|
Injected parameters: `{today}` (date) and `{X}` (= `DailyPrepMaxTasks`).
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- No candidates → Claude marks nothing; runner reports "0 added".
|
||||||
|
- Claude run fails / times out → log + failure broadcast (existing scheduler event channel);
|
||||||
|
`LastRunAt` is set on attempt, as today, to avoid tight retry loops.
|
||||||
|
- `set_my_day` on an invalid/ineligible id → tool returns an error string; Claude adapts.
|
||||||
|
- Cap exceeded → tool returns an error; Claude stops adding.
|
||||||
|
- Concurrent trigger → single-flight guard reports "already running".
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Real SQLite + real git (project convention).
|
||||||
|
|
||||||
|
- `get_daily_prep_candidates`: only `Idle`; blocked excluded; tasks in excluded repos
|
||||||
|
(`ReportExcludedPaths`) excluded; current MyDay tasks included.
|
||||||
|
- `set_my_day`: sets flag + `SortOrder`; broadcasts `TaskUpdated`; cap-guard rejects at limit;
|
||||||
|
unset always allowed.
|
||||||
|
- `DailyPrepRunner`: prompt contains `{X}` + date; args contain `--allowedTools` +
|
||||||
|
permission-mode + `--max-turns`; success/failure outcomes via an `IClaudeProcess` fake.
|
||||||
|
- Rename `IPrimeRunner` → `IDailyPrepRunner` requires syncing `PrimeScheduler` tests/fakes.
|
||||||
|
|
||||||
|
## Files to Create / Modify (high level)
|
||||||
|
|
||||||
|
**Data**
|
||||||
|
- `Models/AppSettingsEntity.cs` — add `DailyPrepMaxTasks`.
|
||||||
|
- `Configuration/AppSettingsEntityConfiguration.cs` — map new column.
|
||||||
|
- `Migrations/` — new migration for `daily_prep_max_tasks`.
|
||||||
|
- `Repositories/AppSettingsRepository.cs` — persist new field.
|
||||||
|
|
||||||
|
**Worker**
|
||||||
|
- `External/ExternalMcpService.cs` — add `get_daily_prep_candidates`, `set_my_day` (+ cap-guard).
|
||||||
|
- `Prime/PrimeRunner.cs` → `DailyPrepRunner.cs`; `Prime/Interfaces/IPrimeRunner.cs`
|
||||||
|
→ `IDailyPrepRunner.cs`; prompt builder + arg builder.
|
||||||
|
- `Prime/PrimeScheduler.cs` — depend on `IDailyPrepRunner`.
|
||||||
|
- `Hub/WorkerHub.cs` — AppSettings DTO field; `RunDailyPrepNow()`.
|
||||||
|
- `Program.cs` — DI registration update.
|
||||||
|
|
||||||
|
**UI**
|
||||||
|
- `Services/WorkerClient.cs` + AppSettings DTO mirror — new field; `RunDailyPrepNow` call.
|
||||||
|
- Prime Claude settings tab VM/view — numeric editor for `DailyPrepMaxTasks`.
|
||||||
|
- MyDay list header — "Tag vorbereiten" button + command (Lists/IslandsShell VM).
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
- `ClaudeDo.Worker.Tests` — MCP tools, runner, scheduler fakes.
|
||||||
|
- `ClaudeDo.Data.Tests` — AppSettings persistence (if covered there).
|
||||||
|
- `ClaudeDo.Ui.Tests` — settings VM / button wiring as applicable.
|
||||||
|
|
||||||
|
## Future Phase (out of scope)
|
||||||
|
|
||||||
|
External ticket sources (Jira, possibly a second system) feed into the candidate pool used by
|
||||||
|
`get_daily_prep_candidates`, behind a task-source abstraction. Designed separately.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Daily Prep — Live Output View + Clear Day — Design
|
||||||
|
|
||||||
|
Date: 2026-06-03
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Two follow-ups to the daily-prep ("Prime Claude") feature:
|
||||||
|
|
||||||
|
1. **Live output view.** While Claude prepares the day, there is no feedback. Add a
|
||||||
|
live, human-readable view of the prep run's output, shown as a new content mode in
|
||||||
|
the existing right-hand **Details island** (mirroring how Daily Notes works — a mode
|
||||||
|
swap, not a separate window/column).
|
||||||
|
2. **Clear Day button.** A MyDay-header button that clears the MyDay selection
|
||||||
|
immediately.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- See the prep run's progress live, rendered with the same friendly terminal renderer
|
||||||
|
used for task runs (assistant text + tool calls like `set_my_day …`, not raw NDJSON).
|
||||||
|
- Both manual (button) and scheduled prep runs stream into the log.
|
||||||
|
- The manual button opens the prep view; a scheduled run fills the log silently and is
|
||||||
|
opened via a dedicated "Vorbereitungs-Log" button (the existing `PrimeStatus` footer
|
||||||
|
remains the hint that a run happened).
|
||||||
|
- A "Tag leeren" button clears all MyDay tasks (any status) with no confirmation.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No new island/column and no popup/overlay — reuse the Details island as a mode swap.
|
||||||
|
- No persistence of prep output across app restarts (in-memory log only).
|
||||||
|
- No undo for Clear Day (re-runnable via "Tag vorbereiten").
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
| --- | --- |
|
||||||
|
| Rendering | Reuse the existing `SessionTerminalView` / `StreamLineFormatter` renderer. |
|
||||||
|
| Location | New `IsPrepMode` content panel inside the Details island (like `IsNotesMode`). |
|
||||||
|
| Lifecycle | Manual click opens the view (UI-local); `PrepStarted/PrepLine/PrepFinished` events fill the log regardless of current mode; scheduled runs do not auto-open. |
|
||||||
|
| Open after schedule | Dedicated "Vorbereitungs-Log" header button + existing `PrimeStatus` footer hint. |
|
||||||
|
| Clear Day scope | All MyDay tasks regardless of status. |
|
||||||
|
| Clear Day confirm | None — clear directly. |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Feature A — Live prep output
|
||||||
|
|
||||||
|
**Worker**
|
||||||
|
- Extend `IPrimeBroadcaster` (`src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs`)
|
||||||
|
with `PrepStartedAsync()`, `PrepLineAsync(string line)`, `PrepFinishedAsync(bool success)`.
|
||||||
|
- Implement in `HubBroadcaster` (`src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`) sending
|
||||||
|
SignalR events `PrepStarted`, `PrepLine` (string), `PrepFinished` (bool).
|
||||||
|
- `PrimeRunner` (`src/ClaudeDo.Worker/Prime/PrimeRunner.cs`): inject `IPrimeBroadcaster`.
|
||||||
|
In `FireAsync`, after the single-flight gate is entered and a run will actually happen:
|
||||||
|
call `PrepStartedAsync()` before `RunAsync`; replace the discard lambda with
|
||||||
|
`async line => await _broadcaster.PrepLineAsync(line)`; call
|
||||||
|
`PrepFinishedAsync(result.IsSuccess)` after. The "already running" early-return path
|
||||||
|
emits nothing (no run occurs). Both scheduled and manual runs go through `FireAsync`,
|
||||||
|
so both stream.
|
||||||
|
|
||||||
|
**UI**
|
||||||
|
- `WorkerClient` (`src/ClaudeDo.Ui/Services/WorkerClient.cs`): register
|
||||||
|
`_hub.On<…>("PrepStarted"/"PrepLine"/"PrepFinished", …)` each via
|
||||||
|
`Dispatcher.UIThread.Post`, raising `PrepStartedEvent` / `PrepLineEvent(string)` /
|
||||||
|
`PrepFinishedEvent(bool)`. Declare these on `IWorkerClient`.
|
||||||
|
- `DetailsIslandViewModel`: add `IsPrepMode` (bool), `IsPrepRunning` (bool), a dedicated
|
||||||
|
`PrepLog` (`ObservableCollection<LogLineViewModel>`), and `ShowPrep()` (calls
|
||||||
|
`Bind(null)`, sets `IsNotesMode=false`, `IsPrepMode=true`). Subscribe to the three prep
|
||||||
|
events in the ctor (always active, independent of mode):
|
||||||
|
- `PrepStarted` → clear `PrepLog`, `IsPrepRunning=true`.
|
||||||
|
- `PrepLine` → format the line with the same `StreamLineFormatter` path used by the
|
||||||
|
stdout branch of `OnTaskMessage`, append a `LogLineViewModel` to `PrepLog`.
|
||||||
|
- `PrepFinished` → `IsPrepRunning=false` (optionally append a status line).
|
||||||
|
Mode exclusivity: the normal task-details panel becomes visible on
|
||||||
|
`!IsNotesMode && !IsPrepMode`; `ShowNotes()` also sets `IsPrepMode=false`; `Bind(task)`
|
||||||
|
resets both flags.
|
||||||
|
- `DetailsIslandView.axaml`: add a third `<Panel IsVisible="{Binding IsPrepMode}">` in the
|
||||||
|
body grid alongside the existing details/notes panels, rendering `PrepLog` in the
|
||||||
|
terminal style (reuse the `LogLineViewModel` item template used by `SessionTerminalView`).
|
||||||
|
|
||||||
|
**Wiring**
|
||||||
|
- `TasksIslandViewModel`: add a `PrepRequested` event (mirror `NotesRequested`).
|
||||||
|
`PrepareDayCommand` raises `PrepRequested` in addition to calling
|
||||||
|
`RunDailyPrepNowAsync()`. Add `ShowPrepLogCommand` that raises `PrepRequested`. Add the
|
||||||
|
"Vorbereitungs-Log" button to the MyDay header (`IsVisible="{Binding IsMyDayList}"`).
|
||||||
|
- `IslandsShellViewModel`: wire `Tasks.PrepRequested += () => Details.ShowPrep()`.
|
||||||
|
|
||||||
|
### Feature B — Clear Day
|
||||||
|
|
||||||
|
**Worker**
|
||||||
|
- `WorkerHub.ClearMyDay()` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs`): query ids where
|
||||||
|
`IsMyDay == true`; `ExecuteUpdateAsync` setting `is_my_day = false`; broadcast
|
||||||
|
`TaskUpdated(id)` for each affected id (the UI reloads the current list on `TaskUpdated`).
|
||||||
|
|
||||||
|
**UI**
|
||||||
|
- `IWorkerClient.ClearMyDayAsync()` + `WorkerClient` impl invoking `"ClearMyDay"`.
|
||||||
|
- `TasksIslandViewModel.ClearDayCommand` calls `_worker.ClearMyDayAsync()` (no confirm).
|
||||||
|
Add the "Tag leeren" button to the MyDay header next to "Tag vorbereiten".
|
||||||
|
|
||||||
|
## Data Flow (live view)
|
||||||
|
|
||||||
|
1. Trigger (schedule or button) → `PrimeRunner.FireAsync`.
|
||||||
|
2. `PrepStartedAsync()` → SignalR `PrepStarted` → `WorkerClient.PrepStartedEvent` →
|
||||||
|
`DetailsIslandViewModel` clears `PrepLog`, sets `IsPrepRunning`.
|
||||||
|
3. Each Claude stdout line → `PrepLineAsync(line)` → `PrepLine` → formatted, appended to
|
||||||
|
`PrepLog` (visible if the user is in prep mode; filled silently otherwise).
|
||||||
|
4. Run ends → `PrepFinishedAsync(success)` → `PrepFinished` → `IsPrepRunning=false`.
|
||||||
|
5. Manual button click also raised `PrepRequested` → `Details.ShowPrep()` (view open).
|
||||||
|
After a scheduled run, the user clicks "Vorbereitungs-Log" to open it.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Prep run fails/times out → `PrepFinished(false)`; the existing `PrimeFired` footer
|
||||||
|
status still reports failure.
|
||||||
|
- "Already running" → no prep events emitted (no run happened); existing behavior intact.
|
||||||
|
- `ClearMyDay` with zero MyDay tasks → no-op, no broadcasts.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Worker: `PrimeRunner` streams `PrepStarted` → N×`PrepLine` → `PrepFinished` (fake
|
||||||
|
`IClaudeProcess` invokes `onStdoutLine` with sample lines; fake `IPrimeBroadcaster`
|
||||||
|
records calls). `WorkerHub.ClearMyDay` clears all IsMyDay rows and broadcasts per id
|
||||||
|
(real SQLite, mirror existing hub tests).
|
||||||
|
- UI: `DetailsIslandViewModel` appends to `PrepLog` on `PrepLineEvent` and `ShowPrep()`
|
||||||
|
sets the mode flags (mutual exclusivity with notes); `TasksIslandViewModel.ClearDayCommand`
|
||||||
|
calls `ClearMyDayAsync` (stub worker client).
|
||||||
|
|
||||||
|
## Files (high level)
|
||||||
|
|
||||||
|
**Modify**
|
||||||
|
- `src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs`
|
||||||
|
- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`
|
||||||
|
- `src/ClaudeDo.Worker/Prime/PrimeRunner.cs`
|
||||||
|
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (ClearMyDay)
|
||||||
|
- `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
||||||
|
- `src/ClaudeDo.Localization/locales/en.json`, `de.json` (button labels)
|
||||||
|
|
||||||
|
**Test**
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs`
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Hub/…` (ClearMyDay)
|
||||||
|
- `tests/ClaudeDo.Ui.Tests/…` (DetailsIslandViewModel prep events; TasksIslandViewModel ClearDay) + `StubWorkerClient`
|
||||||
|
|
||||||
|
## Known fragility
|
||||||
|
|
||||||
|
Changing `IWorkerClient` / `WorkerClient` / VM constructors breaks hand-rolled fakes
|
||||||
|
(`StubWorkerClient`, `FakeWorkerClient`) in both test projects — update all of them.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Localization (i18n) Support — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-03
|
||||||
|
**Status:** Approved (pending spec review)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add translation support to ClaudeDo. The user picks a language in the Settings modal and **all** UI text reflects it instantly (no restart). The WPF installer is localized the same way and gets its own language picker. Ship **English only** now, but the system is fully data-driven: adding a new language means dropping one JSON file into a folder — **no code changes, no rebuild**.
|
||||||
|
|
||||||
|
## Decisions (from brainstorming)
|
||||||
|
|
||||||
|
- **Languages:** English only at launch; extensible via translation files.
|
||||||
|
- **Switching:** Live / instant — all bound UI text updates the moment the language changes.
|
||||||
|
- **Storage:** Selected language stored in `~/.todo-app/ui.config.json` (the local UI config that also holds `DbPath`/`SignalRUrl`). Purely a UI concern — does **not** go through the worker/SignalR settings path.
|
||||||
|
- **Installer:** Defaults to existing config language (upgrade) → OS culture → English. Shows a language picker in the wizard, live-switches its own UI, and writes the chosen language into `ui.config.json` so the app launches matching the installer.
|
||||||
|
- **Locale files:** Loose `*.json` files in a `locales/` folder next to the running exe, scanned at startup to discover available languages.
|
||||||
|
- **Code sharing:** A shared `ClaudeDo.Localization` project holds the loading/lookup/language-list logic, referenced by `ClaudeDo.Ui`, `ClaudeDo.App`, and `ClaudeDo.Installer`. Each UI framework keeps its own thin markup-extension binding layer (Avalonia ≠ WPF).
|
||||||
|
|
||||||
|
## Architecture & Components
|
||||||
|
|
||||||
|
### New shared project: `ClaudeDo.Localization`
|
||||||
|
|
||||||
|
- **`LocaleStore`** — discovers and loads `*.json` files from the `locales/` folder next to the running exe. Parses each file's nested JSON, **flattens it into an internal `Dictionary<string,string>`** keyed by dot-path for O(1) lookup, and captures `metadata.code` / `metadata.name`. Exposes the list of available languages for the dropdowns.
|
||||||
|
- **`ILocalizer` / `Localizer`** — singleton holding the *active* language dictionary. Members:
|
||||||
|
- indexer `this[string key]` → translated string (with fallback),
|
||||||
|
- `string Get(string key, params object[] args)` → `string.Format` for parameterized strings,
|
||||||
|
- `void SetLanguage(string code)` → swaps the active dictionary and raises `PropertyChanged` for the indexer so **all live bindings refresh** (this is what enables instant switching),
|
||||||
|
- `AvailableLanguages` (list of `{ code, name }`), `CurrentCode`.
|
||||||
|
- **Fallback chain:** requested key in active language → same key in English → the key path string itself (a missing translation is visible, never a crash).
|
||||||
|
- **OS-culture resolution:** helper that maps the current OS UI culture to an available locale code, falling back to English.
|
||||||
|
|
||||||
|
### Per-framework binding layer (not shared)
|
||||||
|
|
||||||
|
- **Avalonia:** a `{loc:Tr Some.Key}` markup extension that binds to `Localizer[key]` (Source = the singleton `Localizer`, Path = `[key]`). Language change raises the indexer `PropertyChanged`, refreshing every binding.
|
||||||
|
- **WPF installer:** an equivalent markup extension doing the same against the installer's own `Localizer` instance.
|
||||||
|
|
||||||
|
Both consume the **same JSON files and the same `LocaleStore`/`Localizer` logic** from the shared project.
|
||||||
|
|
||||||
|
## Translation File Format
|
||||||
|
|
||||||
|
`locales/en.json` (and future `de.json`, `fr.json`, …) — nested, human-friendly hierarchy:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metadata": { "code": "en", "name": "English" },
|
||||||
|
"settings": {
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"general": { "model": "Model", "maxParallel": "Max parallel executions" }
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"addPlaceholder": "Add a task…",
|
||||||
|
"overdue": "OVERDUE"
|
||||||
|
},
|
||||||
|
"worktrees": { "autoCleanupDays": "{0} days" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `metadata.code` is the language id stored in `ui.config.json` and matched to OS culture; `metadata.name` is the dropdown label.
|
||||||
|
- **Lookup by dot-path key** (`"settings.general.model"`). On-disk file stays grouped/nested; the runtime flattens it for fast lookup. Authors edit a clean hierarchy.
|
||||||
|
- **Parameters:** `{0}`, `{1}` placeholders resolved via `Get(key, args)`.
|
||||||
|
- **Encoding:** UTF-8 — non-ASCII languages work out of the box.
|
||||||
|
|
||||||
|
## Data Flow & Wiring
|
||||||
|
|
||||||
|
### App config
|
||||||
|
|
||||||
|
- Add `Language` (string, e.g. `"en"`) to `AppSettings` (`ClaudeDo.Ui/AppSettings.cs`) and to the installer mirror `InstallerAppSettings` (`ClaudeDo.Installer/Core/ConfigModels.cs`).
|
||||||
|
- Add a `Save()` method to `AppSettings` (today the UI only reads it).
|
||||||
|
|
||||||
|
### App startup (`ClaudeDo.App/Program.cs`)
|
||||||
|
|
||||||
|
1. `AppSettings.Load()` reads `Language` (missing/empty → resolve from OS culture, else `"en"`).
|
||||||
|
2. `LocaleStore` scans `locales/` next to the exe; `Localizer` is registered as a singleton and set to the configured language.
|
||||||
|
3. UI renders; every `{loc:Tr ...}` binding pulls from the active dictionary.
|
||||||
|
|
||||||
|
### Changing language in Settings (General tab)
|
||||||
|
|
||||||
|
- New "Language" dropdown bound to `Localizer.AvailableLanguages`; selection bound to current code.
|
||||||
|
- On change → `Localizer.SetLanguage(code)` (instant UI refresh) **and** `AppSettings.Language = code; AppSettings.Save()`. Local UI state only — not routed through worker/SignalR.
|
||||||
|
|
||||||
|
### Installer (`ClaudeDo.Installer`)
|
||||||
|
|
||||||
|
- On launch: default language = existing `ui.config.json` `Language` if present (upgrade), else OS culture, else English.
|
||||||
|
- Wizard gets a language dropdown (same `LocaleStore`, installer's own markup extension) → live-switches the installer UI.
|
||||||
|
- When writing `ui.config.json`, persists the chosen `Language` so the app launches matching the installer.
|
||||||
|
|
||||||
|
### Build wiring
|
||||||
|
|
||||||
|
- `locales/*.json` copied to output (`CopyToOutputDirectory`) for both App and Installer.
|
||||||
|
- Installer packages the `locales/` folder so it lands beside the installed exe.
|
||||||
|
|
||||||
|
## String-Extraction Scope
|
||||||
|
|
||||||
|
Mechanical but large; done screen-by-screen so each commit is reviewable, building one `en.json` as the single source of truth.
|
||||||
|
|
||||||
|
- **22 Avalonia `.axaml` views** — replace inline `Text="..."`, `Content="..."`, `PlaceholderText="..."`, and inline `ComboBoxItem` text with `{loc:Tr key}`.
|
||||||
|
- **ViewModel strings** — user-facing literals built in C# (e.g. `HeaderTitle`, `StatusPill`, status text, parameterized messages) resolve via injected `ILocalizer` (`localizer.Get(...)`). Log messages and non-user-facing strings stay as-is. **Live-switch note:** a VM string resolved once will not refresh on language change. For VM-built user-facing text, either (a) prefer resolving in XAML via `{loc:Tr}` where possible, or (b) have the VM subscribe to the `Localizer` change event and re-raise `PropertyChanged` (or re-resolve) for its localized properties. Decide per-property during extraction.
|
||||||
|
- **10 WPF installer files** — same treatment with the installer's markup extension; VM-driven headings (`Heading`, `NextButtonText`, etc.) go through `ILocalizer`.
|
||||||
|
- **Enum-ish display values** (model names, permission modes, weekday names) — translate the *display* text while keeping the underlying value/binding intact.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `ClaudeDo.Localization` unit tests: load/flatten nested JSON, dot-path lookup, fallback chain (active→en→key), `{0}` formatting, OS-culture resolution.
|
||||||
|
- `LocaleStore` discovery test (folder scan → available languages).
|
||||||
|
- **Key-coverage test:** every locale file's flattened key set matches `en.json`; fails the build if `en.json` drifts from other locale files.
|
||||||
|
- Settings round-trip test: `SetLanguage` updates `Localizer` **and** persists to `ui.config.json`.
|
||||||
|
- Manual UI pass (user's visual review): confirm instant switching with a throwaway `de.json` stub during dev, then remove it.
|
||||||
|
|
||||||
|
## Out of Scope (YAGNI)
|
||||||
|
|
||||||
|
- Pluralization rules, RTL layout, per-string gender.
|
||||||
|
- Translating the German weekly-report **body** (generated content — stays as-is).
|
||||||
|
- Localizing log output and non-user-facing strings.
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# Weekly Report — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-03
|
||||||
|
**Status:** Approved (pending spec review)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Generate a short, standup-focused report of what the user did over the past week,
|
||||||
|
for the Wednesday standup. The report is built from the user's Claude Code session
|
||||||
|
history across all repos, distilled and summarized by Claude. Personal repos under a
|
||||||
|
configurable excluded path (default `C:\Private`) are left out. The user can author
|
||||||
|
per-day bullet notes inside ClaudeDo (via the My Day list) that are folded into the
|
||||||
|
report.
|
||||||
|
|
||||||
|
## Decisions (from brainstorming)
|
||||||
|
|
||||||
|
- **Data source:** all Claude Code history in `~/.claude/projects/*/*.jsonl`, both manual
|
||||||
|
sessions and ClaudeDo-run tasks, grouped by repo.
|
||||||
|
- **Exclusion:** a configurable list of path prefixes (default `["C:\\Private"]`). Any
|
||||||
|
session whose `cwd` starts with an excluded prefix is dropped.
|
||||||
|
- **Summarization:** Claude CLI summarizes. The Worker distills the logs, then runs a
|
||||||
|
single one-shot `claude -p` call via the existing `ClaudeProcess` and returns the
|
||||||
|
result markdown. No worktree, no task row, no queue.
|
||||||
|
- **Period:** default "since last Wednesday → today", computed from a configurable
|
||||||
|
standup weekday. The range is adjustable in the modal.
|
||||||
|
- **Signal fed to Claude:** user prompts (intent), assistant closing summaries, and the
|
||||||
|
user's daily notes. No git-commit scanning.
|
||||||
|
- **Report shape:** German, grouped by day, first-person past-tense bullets, ~3-5
|
||||||
|
bullets/day with trivia merged/dropped, notes blended into one deduplicated list per
|
||||||
|
day. See the Report Prompt section.
|
||||||
|
- **Placement:** a "Weekly Report" overlay modal opened from the toolbar, rendering via
|
||||||
|
the existing `MarkdownView`.
|
||||||
|
- **Output:** view-only in-app (no export).
|
||||||
|
- **Notes UI:** authored in the My Day list via a pinned non-task "Notes" pseudo-row that
|
||||||
|
repurposes the Details island into a bullet-notes editor. Per-day bullets with a day
|
||||||
|
navigator (prev/next arrows + date picker + Today).
|
||||||
|
- **Report persistence:** generated reports are stored, keyed by exact date range, and
|
||||||
|
reused. Generation is button-driven (never automatic); a Regenerate button overwrites.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
UI (WeeklyReportModal, Details-island notes mode)
|
||||||
|
│ SignalR
|
||||||
|
▼
|
||||||
|
WorkerHub ── GetWeekReport / GenerateWeekReport / daily-notes CRUD
|
||||||
|
│
|
||||||
|
├── WeekReportService ──► ClaudeHistoryReader (scan ~/.claude/projects)
|
||||||
|
│ │ (distilled activity)
|
||||||
|
│ ├── DailyNoteRepository (notes in window)
|
||||||
|
│ ├── ClaudeProcess (one-shot summarize)
|
||||||
|
│ └── WeekReportRepository (store/reuse)
|
||||||
|
└── DailyNoteRepository (CRUD)
|
||||||
|
|
||||||
|
Data: DailyNoteEntity, WeekReportEntity + repositories + EF migration
|
||||||
|
AppSettingsEntity: ReportExcludedPaths, StandupWeekday
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Data layer (`ClaudeDo.Data`)
|
||||||
|
|
||||||
|
**`DailyNoteEntity`** (table `daily_notes`)
|
||||||
|
- `Id` (GUID string, init-only PK)
|
||||||
|
- `Date` (date-only; the day the bullet belongs to)
|
||||||
|
- `Text` (string, the bullet content)
|
||||||
|
- `SortOrder` (int; ordering within a day)
|
||||||
|
- `CreatedAt` (DateTime)
|
||||||
|
|
||||||
|
**`DailyNoteRepository`** (async, CancellationToken, follows existing repo pattern)
|
||||||
|
- `ListByDayAsync(DateOnly day)` — bullets for one day, ordered by `SortOrder`.
|
||||||
|
- `ListBetweenAsync(DateOnly start, DateOnly end)` — bullets in a window (used by the report).
|
||||||
|
- `AddAsync(DateOnly day, string text)` — appends a bullet (assigns next `SortOrder`).
|
||||||
|
- `UpdateAsync(string id, string text)`
|
||||||
|
- `DeleteAsync(string id)`
|
||||||
|
|
||||||
|
**`WeekReportEntity`** (table `week_reports`)
|
||||||
|
- `Id` (GUID string, init-only PK)
|
||||||
|
- `StartDate`, `EndDate` (date-only; the report window — unique together)
|
||||||
|
- `Markdown` (string; the generated report)
|
||||||
|
- `GeneratedAt` (DateTime)
|
||||||
|
|
||||||
|
**`WeekReportRepository`**
|
||||||
|
- `GetByRangeAsync(DateOnly start, DateOnly end)` — stored report for an exact range, or null.
|
||||||
|
- `UpsertAsync(DateOnly start, DateOnly end, string markdown)` — insert or overwrite by range.
|
||||||
|
|
||||||
|
**`AppSettingsEntity`** — two new columns:
|
||||||
|
- `ReportExcludedPaths` (string, JSON array of path prefixes; default `["C:\\Private"]`)
|
||||||
|
- `StandupWeekday` (int, `DayOfWeek`; default `Wednesday` = 3)
|
||||||
|
|
||||||
|
**Migration** — one EF migration adds `daily_notes`, `week_reports`, and the two
|
||||||
|
`app_settings` columns. Entity configs in `Configuration/` (date-only and enum/JSON
|
||||||
|
conversion via `ValueConverter`, per existing convention).
|
||||||
|
|
||||||
|
### 2. Worker (`ClaudeDo.Worker`) — new `Report/` folder
|
||||||
|
|
||||||
|
**`ClaudeHistoryReader`** (raw → distilled)
|
||||||
|
- Input: date window + excluded path prefixes.
|
||||||
|
- Enumerates `~/.claude/projects/*/*.jsonl`.
|
||||||
|
- Parses each line as JSON; tolerant of malformed lines (skip, never throw).
|
||||||
|
- Drops a session entirely if its `cwd` starts with any excluded prefix
|
||||||
|
(case-insensitive, normalized separators).
|
||||||
|
- Keeps messages whose `timestamp` falls in `[start, end]`.
|
||||||
|
- Extracts, per repo (`cwd`) → per day:
|
||||||
|
- **user prompts**: `type == "user"` text content (string or `content[].text`).
|
||||||
|
Skip tool-result-only user turns and queue/attachment/hook noise.
|
||||||
|
- **assistant closing summaries**: the final assistant text block of each turn/session.
|
||||||
|
- Output: a structured model, e.g.
|
||||||
|
`IReadOnlyList<RepoActivity>` where `RepoActivity { RepoPath, Days: List<DayActivity{ Date, Prompts[], Summaries[] }> }`.
|
||||||
|
|
||||||
|
**`WeekReportService`** (distilled → stored summary)
|
||||||
|
- `GenerateAsync(start, end, ct)`:
|
||||||
|
1. Read settings (excluded paths, standup weekday).
|
||||||
|
2. `ClaudeHistoryReader` → distilled activity.
|
||||||
|
3. `DailyNoteRepository.ListBetweenAsync` → notes grouped by day.
|
||||||
|
4. Pivot the distilled activity (repo→day from the reader) into **day-major**
|
||||||
|
(day→repo) to match the day-grouped report, and build the prompt from the
|
||||||
|
template in the Report Prompt section. Empty window → produce a "no activity"
|
||||||
|
report without calling Claude.
|
||||||
|
5. Run `ClaudeProcess` once (`claude -p`, no worktree/agents; working dir = a neutral
|
||||||
|
dir). Read `RunResult.ResultMarkdown`.
|
||||||
|
6. `WeekReportRepository.UpsertAsync(start, end, markdown)`; return markdown.
|
||||||
|
7. On Claude failure, surface `RunResult.ErrorMarkdown` to the caller (do not store).
|
||||||
|
- `GetStoredAsync(start, end)` → `WeekReportRepository.GetByRangeAsync`.
|
||||||
|
|
||||||
|
Interfaces live in `Report/Interfaces/` per the area convention.
|
||||||
|
|
||||||
|
#### Report Prompt
|
||||||
|
|
||||||
|
`WeekReportService` assembles this prompt. Instructions are in English (more reliable
|
||||||
|
steering); the output is forced to German. `{...}` are filled at build time.
|
||||||
|
|
||||||
|
```
|
||||||
|
You are generating a concise weekly standup report for a software developer.
|
||||||
|
Summarize what they accomplished between {start:dd.MM.yyyy} and {end:dd.MM.yyyy}.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Write the ENTIRE report in German.
|
||||||
|
- Group by day. One "## {Wochentag}, {dd.MM.yyyy}" section per day that has
|
||||||
|
activity (German weekday names). Omit days with no activity entirely.
|
||||||
|
- Within each day: 3–5 first-person, past-tense bullets ("- Habe X umgesetzt",
|
||||||
|
"- Y behoben"). Merge related small work into one bullet.
|
||||||
|
- Drop trivia: typo fixes, pure exploration, false starts, tooling/log noise.
|
||||||
|
- Blend the developer's own notes and the derived activity into ONE deduplicated
|
||||||
|
bullet list per day. The developer's notes are authoritative — never omit or
|
||||||
|
contradict their substance.
|
||||||
|
- Name the project/repo when it adds clarity.
|
||||||
|
- Output ONLY the dated sections. No preamble, no intro, no closing remarks.
|
||||||
|
|
||||||
|
== Activity (from session history) ==
|
||||||
|
{day-major: for each day → for each repo → its prompts + closing summaries}
|
||||||
|
|
||||||
|
== Developer notes ==
|
||||||
|
{day-major: for each day → the bullets}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. IPC (Hub + WorkerClient)
|
||||||
|
|
||||||
|
**`WorkerHub`** new methods:
|
||||||
|
- `GetWeekReport(string startIso, string endIso)` → stored markdown or null.
|
||||||
|
- `GenerateWeekReport(string startIso, string endIso)` → generates, stores, returns markdown.
|
||||||
|
- `GetDailyNotes(string dayIso)` → bullets for a day.
|
||||||
|
- `AddDailyNote(string dayIso, string text)` → created bullet.
|
||||||
|
- `UpdateDailyNote(string id, string text)`.
|
||||||
|
- `DeleteDailyNote(string id)`.
|
||||||
|
|
||||||
|
**`WorkerClient`** (UI) mirrors these, following the existing
|
||||||
|
`WorkerPrimeScheduleApi`/AppSettings method pattern.
|
||||||
|
|
||||||
|
### 4. UI (`ClaudeDo.Ui`)
|
||||||
|
|
||||||
|
**Weekly Report modal** (`WeeklyReportModalView` + `WeeklyReportModalViewModel`)
|
||||||
|
- Overlay modal in the `Modals/` pattern (like `WorktreesOverviewModalView`),
|
||||||
|
registered in `IslandsShellViewModel`, opened from a new toolbar button.
|
||||||
|
- Date range: two `ThemedDatePicker`s, default "since last Wednesday → today" computed
|
||||||
|
from `StandupWeekday`.
|
||||||
|
- On open and on range change: call `GetWeekReport`.
|
||||||
|
- Stored report exists → render markdown via `MarkdownView`, show `GeneratedAt`, show
|
||||||
|
a **Regenerate** button.
|
||||||
|
- None → empty state ("Not generated yet") + a **Generate** button.
|
||||||
|
- **Generate**/**Regenerate**: call `GenerateWeekReport` with a busy/spinner state;
|
||||||
|
render the returned markdown. Generation only ever runs from these buttons.
|
||||||
|
- View-only; no export.
|
||||||
|
|
||||||
|
**Notes in My Day**
|
||||||
|
- The My Day smart list (`smart:my-day`) pins a fixed, non-task "Notes" pseudo-row at
|
||||||
|
the top, recognized by the list/selection code (not a `TaskEntity`).
|
||||||
|
- Selecting it puts the **Details island** into **notes mode** (task fields hidden,
|
||||||
|
notes editor shown). The island hosts a dedicated `NotesEditorViewModel` + small view
|
||||||
|
rather than swelling `DetailsIslandViewModel` (already ~978 lines); the bullet logic
|
||||||
|
stays isolated and testable.
|
||||||
|
- **Day navigator** in the editor header: `<` / `>` arrows to step days, a
|
||||||
|
`ThemedDatePicker` to jump to any date, and a "Today" button. Defaults to today; the
|
||||||
|
pinned row's default day rolls over at midnight (no data lost — past days remain
|
||||||
|
reachable via the navigator).
|
||||||
|
- **Bullet editing** for the selected day: list of bullets with add / inline-edit /
|
||||||
|
delete / reorder (`SortOrder`). Each operation goes through the daily-notes hub CRUD.
|
||||||
|
|
||||||
|
### 5. Settings
|
||||||
|
|
||||||
|
- Add the excluded-path list and the standup weekday to the existing Settings modal,
|
||||||
|
persisted via the new `app_settings` columns and the existing
|
||||||
|
`GetAppSettings`/`UpdateAppSettings` path.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Malformed/unreadable JSONL lines are skipped, never fatal.
|
||||||
|
- Empty window → a "no activity" report, no Claude call.
|
||||||
|
- Claude call failure → error surfaced in the modal; nothing stored.
|
||||||
|
- Date ranges normalized to date-only; the stored report key is the exact (start, end).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **`ClaudeHistoryReader`** (Worker tests, fixture `.jsonl`): date-window filtering,
|
||||||
|
excluded-prefix dropping (case/separator normalization), prompt/summary extraction,
|
||||||
|
malformed-line tolerance, repo/day grouping.
|
||||||
|
- **`WeekReportService`**: prompt-building from distilled activity + notes; empty-window
|
||||||
|
short-circuit; storage upsert; with a faked `ClaudeProcess`.
|
||||||
|
- **`DailyNoteRepository`** and **`WeekReportRepository`**: CRUD / upsert / range lookup
|
||||||
|
against real SQLite (matches existing test style).
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Report export (clipboard/file) — view-only for now.
|
||||||
|
- Git-commit scanning.
|
||||||
|
- Editing or summarizing full transcripts; only prompts + closing summaries are used.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Approve = Merge → Done, plus Conflict Preview — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Approved (autonomous — user on break, authorized to continue)
|
||||||
|
**Author:** brainstormed from issue "Make merge/diff real"
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Approving a `WaitingForReview` task flips it straight to `Done`
|
||||||
|
(`TaskStateService.ApproveReviewAsync`) and **never merges** its worktree — the
|
||||||
|
worktree stays `Active`. The user approved three component tasks expecting them
|
||||||
|
to merge; none did. Separately, there is **no way to see whether a task's
|
||||||
|
worktree merges cleanly** before acting, and a standalone task has no direct
|
||||||
|
**Merge** button (single-task merge is only reachable from inside the Diff
|
||||||
|
modal).
|
||||||
|
|
||||||
|
What is already real (verified): `WorkerHub.MergeTask → TaskMergeService.MergeAsync`
|
||||||
|
performs a real `git merge --no-ff`, aborts on conflict, and marks the worktree
|
||||||
|
`Merged`. **Open Diff** opens a real in-app diff. **Merge All Subtasks**
|
||||||
|
(planning) is real. So the gaps are narrow.
|
||||||
|
|
||||||
|
## Scope decisions (autonomous)
|
||||||
|
|
||||||
|
- **Tab location:** keep the **single "Session" tab** that the recent commit
|
||||||
|
`ac9bae9` deliberately consolidated. All new controls go in its existing
|
||||||
|
`MERGE & WORKTREE` block (`WorkConsole.axaml:196`). Do **not** re-introduce a
|
||||||
|
separate "Actions" tab.
|
||||||
|
- **Approve target:** Approve merges into the UI-selected merge target
|
||||||
|
(`SelectedMergeTarget`); when blank, the worker resolves to the repo's current
|
||||||
|
branch.
|
||||||
|
- **On conflict:** task stays in `WaitingForReview` (no new status). The conflict
|
||||||
|
is surfaced inline. No automatic state change to a "blocked" status.
|
||||||
|
- **Worktree removal on approve:** do **not** remove — merge marks the worktree
|
||||||
|
`Merged` and existing auto-cleanup handles disposal (matches the single-task
|
||||||
|
merge default `removeWorktree:false`).
|
||||||
|
- **Applies to:** standalone leaf tasks with an active worktree. A
|
||||||
|
`WaitingForReview` task with **no** active worktree (e.g. ran in a sandbox, or
|
||||||
|
an improvement parent whose children own the worktrees) is just marked `Done`
|
||||||
|
— current behavior preserved. Planning parents keep "Merge All Subtasks".
|
||||||
|
|
||||||
|
## Acceptance (restated)
|
||||||
|
|
||||||
|
1. Approve a clean-merging task → worktree merged into target, worktree `Merged`,
|
||||||
|
task `Done`.
|
||||||
|
2. Approve a conflicting task → task **not** `Done`, conflict surfaced.
|
||||||
|
3. Opening a Done/WaitingForReview task shows clean/conflict status **without
|
||||||
|
mutating** the tree (use `git merge-tree`, not a real merge).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three layers, each single-purpose; the only new cross-dependency is
|
||||||
|
`TaskMergeService → ITaskStateService` (one-way; verify no DI cycle).
|
||||||
|
|
||||||
|
### 1. GitService — non-destructive conflict probe (`ClaudeDo.Data`)
|
||||||
|
|
||||||
|
New method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
|
||||||
|
|
||||||
|
public async Task<MergePreview> PreviewMergeAsync(
|
||||||
|
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Runs `git merge-tree --write-tree --name-only <target> <source>` from `repoDir`.
|
||||||
|
`merge-tree` computes the merge base itself and writes only loose objects — it
|
||||||
|
does **not** touch the working tree, index, or refs.
|
||||||
|
- Exit code `0` → `Clean = true`, no conflict files.
|
||||||
|
- Exit code `1` → `Clean = false`; conflicted paths are the lines after the
|
||||||
|
first (tree-OID) line, up to the first blank line.
|
||||||
|
- Any other outcome (e.g. git too old → "unknown option") → `Supported = false`
|
||||||
|
(UI shows "mergeability unknown").
|
||||||
|
|
||||||
|
New helper for the "· N files" count (clean case):
|
||||||
|
`git diff --name-only <target>...<source>` (three-dot = changes on source since
|
||||||
|
the merge base); count non-empty lines. May reuse/extend existing diff helpers.
|
||||||
|
|
||||||
|
### 2. TaskMergeService — preview + approve orchestration (`ClaudeDo.Worker`)
|
||||||
|
|
||||||
|
Inject `ITaskStateService` (verify `PlanningChainCoordinator` has no back-edge to
|
||||||
|
`TaskMergeService`; if a cycle exists, fall back to orchestrating in the hub).
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record MergePreviewResult(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||||
|
// Status: "clean" | "conflict" | "unavailable"
|
||||||
|
public Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct);
|
||||||
|
|
||||||
|
public Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
**PreviewAsync:** load context. If no active worktree → `"unavailable"`. Resolve
|
||||||
|
`targetBranch` (blank → current branch). Call `GitService.PreviewMergeAsync`; map
|
||||||
|
`Supported=false` → `"unavailable"`, else clean/conflict (+ ChangedFileCount on
|
||||||
|
clean).
|
||||||
|
|
||||||
|
**ApproveAndMergeAsync:** load context; require `task.Status == WaitingForReview`
|
||||||
|
(else `Blocked`). Resolve target (blank → current branch).
|
||||||
|
- **No active worktree** → `_state.ApproveReviewAsync(taskId)` → return
|
||||||
|
`MergeResult(StatusMerged, [], null)` ("approved, nothing to merge").
|
||||||
|
- **Active worktree** → `MergeAsync(taskId, target, removeWorktree:false,
|
||||||
|
"Merge {branch}", ct)`. On `StatusMerged` → `_state.ApproveReviewAsync(taskId)`
|
||||||
|
then return the merged result. On `StatusConflict`/`StatusBlocked` → return as-is;
|
||||||
|
**do not** flip status (task stays `WaitingForReview`).
|
||||||
|
|
||||||
|
`TaskStateService.ApproveReviewAsync` is unchanged (still the sole Status writer;
|
||||||
|
still runs `OnChildTerminalAsync`).
|
||||||
|
|
||||||
|
### 3. WorkerHub — signatures (`ClaudeDo.Worker`)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||||
|
|
||||||
|
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch); // new
|
||||||
|
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch); // CHANGED: was void(taskId)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ApproveReview` returns the orchestration result so the UI can react to conflicts.
|
||||||
|
`MergeTask` / `GetMergeTargets` unchanged.
|
||||||
|
|
||||||
|
### 4. UI (`ClaudeDo.Ui`)
|
||||||
|
|
||||||
|
`IWorkerClient` (+ `WorkerClient` + **both test-project fakes** — see memory:
|
||||||
|
changing `IWorkerClient` breaks hand-rolled fakes):
|
||||||
|
- Change `Task ApproveReviewAsync(string)` → `Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)`.
|
||||||
|
- Add `Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)`.
|
||||||
|
- Add `Task<MergeResultDto> MergeTaskAsync(...)` to the **interface** (already on
|
||||||
|
the concrete client) so the single-task Merge button can use `_worker`.
|
||||||
|
|
||||||
|
`DetailsIslandViewModel`:
|
||||||
|
- **Load merge targets whenever a worktree exists.** In `BindAsync`, when
|
||||||
|
`entity.Worktree != null` and the task is not a planning parent, call
|
||||||
|
`GetMergeTargetsAsync(taskId)` and set `SelectedMergeTarget = DefaultBranch`
|
||||||
|
(fixes the standalone-task gap where targets were never loaded).
|
||||||
|
- **Mergeability indicator** properties: `MergePreviewText` (string),
|
||||||
|
`MergeIsClean` / `MergeIsConflict` (bool, for color). Compute via
|
||||||
|
`PreviewMergeAsync` when the merge section is shown for an **Active** worktree;
|
||||||
|
recompute on `SelectedMergeTarget` change. If worktree state is
|
||||||
|
`Merged/Discarded/Kept`, show that label instead of probing. Text examples:
|
||||||
|
"Merges cleanly · 7 files" / "Conflicts in a.cs, b.cs" / "Mergeability unknown".
|
||||||
|
- **Approve** (`ApproveReviewAsync`): pass `SelectedMergeTarget ?? ""`; inspect
|
||||||
|
result — on `"conflict"` set the conflict indicator + a short notice
|
||||||
|
("Approve blocked — resolve conflicts first"); success path relies on the
|
||||||
|
existing `TaskUpdated` broadcast to refresh.
|
||||||
|
- **Single-task Merge** (`MergeCommand`): `MergeTaskAsync(taskId,
|
||||||
|
SelectedMergeTarget ?? "", removeWorktree:false, "Merge task")`; on `"conflict"`
|
||||||
|
show the conflict indicator. Shown for non-planning tasks with an active
|
||||||
|
worktree (planning parents keep "Merge All Subtasks").
|
||||||
|
|
||||||
|
`WorkConsole.axaml` (Session tab, `MERGE & WORKTREE` block):
|
||||||
|
- Add a status line above the button row bound to `MergePreviewText`, colored
|
||||||
|
green (`MossBrush`) when `MergeIsClean`, red (`BloodBrush`) when
|
||||||
|
`MergeIsConflict`, muted otherwise. Use existing tokens/classes only.
|
||||||
|
- Add a **Merge** button (`MergeCommand`) beside **Open Diff** for the
|
||||||
|
single-task path.
|
||||||
|
|
||||||
|
## Testing (git-backed, no real Claude)
|
||||||
|
|
||||||
|
In `ClaudeDo.Worker.Tests` (real temp git repos + real SQLite), and/or
|
||||||
|
`ClaudeDo.Data.Tests` for the pure git probe:
|
||||||
|
- `GitService.PreviewMergeAsync`: clean branches → `Clean=true`; a real
|
||||||
|
edit-conflict on the same lines → `Clean=false` with the expected file in
|
||||||
|
`ConflictFiles`.
|
||||||
|
- `ApproveAndMergeAsync`: clean worktree → returns `merged`, task is `Done`,
|
||||||
|
worktree state `Merged`. Conflicting worktree → returns `conflict`, task still
|
||||||
|
`WaitingForReview`, worktree still `Active`, target branch unmodified
|
||||||
|
(HEAD unchanged, no `MERGE_HEAD`).
|
||||||
|
- No-worktree `WaitingForReview` task → returns `merged`, task `Done`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
External difftools, new task statuses, auto-removing worktrees on approve,
|
||||||
|
re-splitting the console into separate tabs, conflict resolution UI (the existing
|
||||||
|
`ContinueMerge`/`AbortMerge` paths remain as-is for mid-merge cases).
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Bundled Prompts Overhaul — Design
|
||||||
|
|
||||||
|
Date: 2026-06-04
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace ClaudeDo's bundled prompts with a clean, professional baseline and make
|
||||||
|
every prose prompt a user-editable file with a bundled default. Add a roadblock
|
||||||
|
protocol so an autonomous run can flag problems mid-task without aborting.
|
||||||
|
|
||||||
|
The execution-side defaults (`system.md`) ship as a moderate, **project-agnostic**
|
||||||
|
engineering baseline — ClaudeDo users run tasks against their *own* repos, so no
|
||||||
|
ClaudeDo-specific rules belong there. Everything is in English (tighter
|
||||||
|
tokenization, more reliable instruction-following); the only German output is the
|
||||||
|
weekly report, which a human reads.
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
All prompts live under `~/.todo-app/prompts/` as editable files with bundled
|
||||||
|
defaults seeded by `PromptFiles.EnsureExists` (which never overwrites a file the
|
||||||
|
user already has). The `system` + `agent` prompts collapse into one `system.md`;
|
||||||
|
the old `agent`/manual distinction was removed when tags were retired.
|
||||||
|
|
||||||
|
| File | Replaces | Placeholders |
|
||||||
|
|---|---|---|
|
||||||
|
| `system.md` | system + agent (merged) | — |
|
||||||
|
| `planning-system.md` | planning system prompt | — |
|
||||||
|
| `planning-initial.md` | "analyze & break down" kickoff | `{title}`, `{description}` |
|
||||||
|
| `retry.md` | "try again and fix" prompt | — |
|
||||||
|
| `daily-prep.md` | daily-prep prompt | `{date}`, `{maxTasks}` |
|
||||||
|
| `weekly-report.md` | weekly-report instructions | `{start}`, `{end}` |
|
||||||
|
|
||||||
|
The task-execution prompt (title + description + `## Sub-Tasks` checkboxes) stays
|
||||||
|
assembled in code — it is data-shaped, not prose.
|
||||||
|
|
||||||
|
### Templating
|
||||||
|
|
||||||
|
`PromptFiles` gains `Render(PromptKind kind, IReadOnlyDictionary<string,string> values)`
|
||||||
|
that replaces **only** the known named tokens for that kind. Any other `{...}` in
|
||||||
|
the file (e.g. the literal `{Wochentag}` / `{dd.MM.yyyy}` in the German report
|
||||||
|
rules) passes through untouched. Daily-prep tool names are inlined as literals —
|
||||||
|
`--allowedTools` already carries the real names, and inlining keeps the file from
|
||||||
|
silently breaking if a user edits a placeholder.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
`EnsureExists` keeps its current semantics: it seeds a default only when the file
|
||||||
|
is missing, never overwriting user edits. The old `planning.md` and `agent.md`
|
||||||
|
become inert — `TaskRunner` stops reading `agent.md`, and the planning system
|
||||||
|
prompt now reads `planning-system.md`. Old files are harmless to leave or delete.
|
||||||
|
`PromptKind` changes: `Agent` is removed; `Planning` maps to `planning-system.md`;
|
||||||
|
new kinds `PlanningInitial`, `Retry`, `DailyPrep`, `WeeklyReport` are added.
|
||||||
|
|
||||||
|
## Roadblock protocol
|
||||||
|
|
||||||
|
An autonomous run has no human watching, so it must not silently stop or block on
|
||||||
|
a question. Instead the agent emits an inline marker whenever it hits a true
|
||||||
|
blocker, **any number of times**, and keeps working on whatever it still can.
|
||||||
|
|
||||||
|
- **Prompt side** (`system.md`): instruct the agent to write
|
||||||
|
`CLAUDEDO_BLOCKED: <one short sentence>` on its own line whenever something
|
||||||
|
genuinely prevents progress (missing credentials, contradictory requirements, a
|
||||||
|
destructive action it won't take unasked) — then continue with the rest of the
|
||||||
|
task. Reserved for true blockers, not routine decisions it can make itself.
|
||||||
|
- **Detection** (`StreamAnalyzer`): as `assistant` messages stream, scan their
|
||||||
|
text content for lines matching `^CLAUDEDO_BLOCKED:` and collect each reason
|
||||||
|
into an ordered list (`Blocks`). This is live and cumulative — multiple problems
|
||||||
|
across one run are all captured, not just the last.
|
||||||
|
- **Result wiring** (`StreamResult` → `RunResult` → run record): carry the
|
||||||
|
collected `Blocks`. Strip the marker lines from the displayed result text.
|
||||||
|
- **Routing**: a run that finishes with blocks still goes to `WaitingForReview`
|
||||||
|
(standalone tasks) — it is "done as far as the agent could get". The review card
|
||||||
|
shows a ⚠ roadblock hint listing the collected problems. The user answers them
|
||||||
|
via the existing reject-rerun feedback path, which resumes the session with the
|
||||||
|
answers as the next-turn prompt — so the agent continues with the problems
|
||||||
|
resolved rather than restarting.
|
||||||
|
|
||||||
|
## The prompts
|
||||||
|
|
||||||
|
### `system.md`
|
||||||
|
```markdown
|
||||||
|
# Working Agreement
|
||||||
|
|
||||||
|
You are completing one well-defined task autonomously in a git repository.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Do exactly what the task asks — no unrequested refactors, renames, dependency
|
||||||
|
changes, or "while I'm here" cleanup.
|
||||||
|
- If intent is ambiguous, state the assumption you're making and proceed with the
|
||||||
|
most reasonable reading. Stop only if you genuinely cannot move forward.
|
||||||
|
- Prefer three similar lines over a premature abstraction. Don't build for
|
||||||
|
hypothetical future needs.
|
||||||
|
|
||||||
|
## Working in the repo
|
||||||
|
- Read a file before editing it. Match the conventions already in this codebase —
|
||||||
|
they override generic defaults.
|
||||||
|
- Prefer editing existing files to creating new ones. Don't write comments that
|
||||||
|
just restate the code.
|
||||||
|
- Validate only at real boundaries (user input, external APIs).
|
||||||
|
|
||||||
|
## Finishing
|
||||||
|
- Before claiming done, verify: run the build and relevant tests, confirm they
|
||||||
|
pass, and report what you ran. If you couldn't verify something, say so plainly.
|
||||||
|
- Make focused commits using the repository's existing commit-message convention.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
- Never force-push, hard-reset, or delete branches/files beyond the task's scope
|
||||||
|
without being asked.
|
||||||
|
- Don't introduce injection/XSS/secret-leak issues. Never commit credentials.
|
||||||
|
|
||||||
|
## You are running unattended
|
||||||
|
You run autonomously with no human watching. There is no one to answer mid-task
|
||||||
|
questions, so never stop to ask — make the most reasonable decision, note the
|
||||||
|
assumption, and continue.
|
||||||
|
|
||||||
|
## When you are blocked
|
||||||
|
If something genuinely prevents you from completing part of the task (missing
|
||||||
|
credentials, contradictory requirements, a destructive action you won't take
|
||||||
|
unasked), do NOT silently give up. Write this marker on its own line, then keep
|
||||||
|
working on whatever else you can:
|
||||||
|
|
||||||
|
CLAUDEDO_BLOCKED: <one short sentence describing what blocked you>
|
||||||
|
|
||||||
|
Emit it as many times as needed — once per distinct blocker. Use it only for true
|
||||||
|
blockers, not for routine decisions you can make yourself.
|
||||||
|
```
|
||||||
|
|
||||||
|
> `system.md` also gains an **"Out-of-scope improvements"** section that tells the
|
||||||
|
> agent to file follow-up work via the `SuggestImprovement` tool. That section is
|
||||||
|
> defined in `2026-06-04-child-tasks-and-improvement-loop-design.md` and lands with
|
||||||
|
> that feature.
|
||||||
|
|
||||||
|
### `planning-system.md`
|
||||||
|
```markdown
|
||||||
|
You are the planning assistant for ClaudeDo. Your job is to break a task into
|
||||||
|
smaller, independently executable subtasks — the session ends by creating those
|
||||||
|
subtasks.
|
||||||
|
|
||||||
|
Start every session by invoking the `superpowers:brainstorming` skill (Skill
|
||||||
|
tool) and follow it end to end: clarifying questions one at a time, then 2–3
|
||||||
|
approaches with a recommendation, then a short design. Do not create any subtasks
|
||||||
|
until the user has approved the design.
|
||||||
|
|
||||||
|
You can ONLY shape this task's plan — you cannot edit files or touch other tasks.
|
||||||
|
The tools available to you are: CreateChildTask, ListChildTasks, UpdateChildTask,
|
||||||
|
DeleteChildTask, UpdatePlanningTask, and Finalize. Use nothing else.
|
||||||
|
|
||||||
|
Once the design is approved, create the child tasks with CreateChildTask, then
|
||||||
|
call Finalize. Keep each subtask concrete and self-contained with a clear
|
||||||
|
done-state, ordered so dependencies come first.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `planning-initial.md`
|
||||||
|
```markdown
|
||||||
|
# Task to plan: {title}
|
||||||
|
|
||||||
|
{description}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `retry.md`
|
||||||
|
```markdown
|
||||||
|
The task did not complete on the previous attempt — you may have run out of
|
||||||
|
turns, hit an error, or stopped before finishing.
|
||||||
|
|
||||||
|
Review the work already done in this session and the current state of the
|
||||||
|
repository, identify what is still incomplete or broken, and finish the task.
|
||||||
|
Don't restart from scratch or repeat a failed approach. Verify the result
|
||||||
|
(build + tests) before you stop.
|
||||||
|
```
|
||||||
|
Self-contained — no error injection. The runner appends the captured process
|
||||||
|
output **only when it is a genuine error** (i.e. not the generic
|
||||||
|
`"Claude exited with code N and no result."` fallback), since real session errors
|
||||||
|
are already in the resumed context.
|
||||||
|
|
||||||
|
### `daily-prep.md`
|
||||||
|
```markdown
|
||||||
|
You are preparing my workday for {date}.
|
||||||
|
|
||||||
|
1. Call mcp__claudedo__get_daily_prep_candidates.
|
||||||
|
2. Keep tasks already marked MyDay (currentMyDay) — never remove them.
|
||||||
|
3. Fill MyDay to at most {maxTasks} open tasks TOTAL (currentMyDay counts). Never exceed it.
|
||||||
|
4. Estimate each candidate's effort and pick a feasible mix — not only big items.
|
||||||
|
Prioritize isStarred, due (scheduledFor), and older tasks.
|
||||||
|
5. Place related tasks next to each other using consecutive sortOrder values.
|
||||||
|
6. Apply via mcp__claudedo__set_my_day(taskId, true, sortOrder). Never mark anything
|
||||||
|
outside the candidate list.
|
||||||
|
|
||||||
|
If there are no candidates, do nothing.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `weekly-report.md`
|
||||||
|
```markdown
|
||||||
|
You are generating a concise weekly standup report for a software developer,
|
||||||
|
covering {start} to {end}.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Write the ENTIRE report in German.
|
||||||
|
- Group by day. One "## {Wochentag}, {dd.MM.yyyy}" section per day that has
|
||||||
|
activity (German weekday names). Omit days with no activity.
|
||||||
|
- Within each day: 3–5 first-person, past-tense bullets ("- Habe X umgesetzt",
|
||||||
|
"- Y behoben"). Merge related small work into one bullet.
|
||||||
|
- Drop trivia: typo fixes, pure exploration, false starts, tooling/log noise.
|
||||||
|
- Blend the developer's own notes and the derived activity into ONE deduplicated
|
||||||
|
bullet list per day. The notes are authoritative — never omit or contradict them.
|
||||||
|
- Name the project/repo when it adds clarity.
|
||||||
|
- Output ONLY the dated sections. No preamble, no intro, no closing remarks.
|
||||||
|
|
||||||
|
Two sections follow below: an activity log derived from Claude session history,
|
||||||
|
and the developer's own notes. Base the report on both; the notes are
|
||||||
|
authoritative where they conflict with the derived activity.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Touch points
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Data/PromptFiles.cs` — new `PromptKind` members, new defaults,
|
||||||
|
`Render` helper.
|
||||||
|
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — stop reading `agent.md`; use
|
||||||
|
`retry.md`; conditional stderr append on retry; carry/route `Blocks`.
|
||||||
|
- `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs` — scan assistant text for
|
||||||
|
`CLAUDEDO_BLOCKED:` markers, collect `Blocks`, strip from result.
|
||||||
|
- `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs` / `RunResult` — carry `Blocks`.
|
||||||
|
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — read
|
||||||
|
`planning-system.md` and `planning-initial.md` via `PromptFiles.Render`.
|
||||||
|
- `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` — read `daily-prep.md`.
|
||||||
|
- `src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs` — read `weekly-report.md`.
|
||||||
|
- UI — review card shows the ⚠ roadblock hint with collected problems.
|
||||||
|
- `src/ClaudeDo.Ui/.../FilesSettingsTabViewModel.cs` — expose the new prompt files.
|
||||||
|
- Tests — `PromptFiles` render/seed; `StreamAnalyzer` marker collection; planning/
|
||||||
|
prep/report builders read from files.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- The in-code task-execution assembly (title/description/subtasks) is unchanged.
|
||||||
|
- `ResultSchema` / `--output-schema` remains untouched.
|
||||||
|
- No change to commit-message templating.
|
||||||
|
```
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Reusable Child Tasks + Agent Improvement Loop — Design
|
||||||
|
|
||||||
|
Date: 2026-06-04
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let an executing task agent offload out-of-scope improvements it spots into
|
||||||
|
**child tasks** that run automatically, so ClaudeDo can drive a self-improvement
|
||||||
|
loop. Generalize the parent/child machinery that planning uses today into a
|
||||||
|
reusable subsystem not bound to planning.
|
||||||
|
|
||||||
|
Example: while implementing task X, Claude notices "this module should really be
|
||||||
|
refactored, but that's out of scope" — instead of scope-creeping, it calls a tool
|
||||||
|
that files the refactor as a child of X. The child runs on its own; once all of
|
||||||
|
X's children finish, X surfaces for review with its whole tree visible.
|
||||||
|
|
||||||
|
This builds on the bundled-prompts overhaul (`system.md` gains one instruction to
|
||||||
|
use the offload tool). It is otherwise independent.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
A new task status `WaitingForChildren` is added.
|
||||||
|
|
||||||
|
```
|
||||||
|
Running → WaitingForReview standalone success, no children (existing)
|
||||||
|
Running → WaitingForChildren standalone success, ≥1 child (new)
|
||||||
|
Running → Done planning child success (existing)
|
||||||
|
WaitingForChildren → WaitingForReview all children terminal (new)
|
||||||
|
WaitingForChildren → Cancelled cancel (new)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Improvement-children are created `Idle` **during** the parent's run and stay
|
||||||
|
unqueued until the parent's own run finishes — this avoids the parent and a
|
||||||
|
child working the same repo concurrently.
|
||||||
|
- When the parent's run succeeds and it has ≥1 non-terminal child, the parent goes
|
||||||
|
to `WaitingForChildren` and its children are enqueued (they then run under the
|
||||||
|
normal queue, governed by max-parallel — they are independent, not a forced
|
||||||
|
sequential chain like planning).
|
||||||
|
- Children run automatically and reach `Done` on success without their own review
|
||||||
|
gate (a per-child review would stall the loop). Each child still produces its
|
||||||
|
own worktree/commit; those worktrees are surfaced under the parent for merge.
|
||||||
|
- Children emit `CLAUDEDO_BLOCKED:` markers like any run (see the prompt-overhaul
|
||||||
|
spec). Each child's collected problems roll up onto the **parent's** review card,
|
||||||
|
so a parent in `WaitingForReview` shows "child N reported a problem" alongside
|
||||||
|
its own roadblocks.
|
||||||
|
|
||||||
|
## Worktree topology & merge
|
||||||
|
|
||||||
|
The correctness rule that makes this work:
|
||||||
|
|
||||||
|
- **Children base off the parent's worktree HEAD, not the list's base branch.**
|
||||||
|
The parent's code work lives only on `claudedo/{parentId}` until merged, so a
|
||||||
|
child refactoring code the parent just wrote must branch from the parent's HEAD
|
||||||
|
to see it. (Planning children base off the target branch because a planning
|
||||||
|
parent writes no code — improvement parents do, hence the difference.) The
|
||||||
|
per-run worktree setup takes the base commit from the parent task's recorded
|
||||||
|
worktree HEAD when `ParentTaskId` is set and the parent is a non-planning task.
|
||||||
|
- **Fan-out:** all children branch off the same parent HEAD and run independently
|
||||||
|
(parallel allowed). Parent-dependency is always satisfied; sibling overlaps
|
||||||
|
surface later as merge conflicts.
|
||||||
|
- **Merge reuses the planning orchestrator,** generalized into a shared
|
||||||
|
"tree merge": build an integration branch off the target, then sequentially
|
||||||
|
`merge --no-ff` the **parent's own branch** followed by each child branch,
|
||||||
|
pausing on conflict (continue / abort), exactly as `PlanningMergeOrchestrator`
|
||||||
|
/`PlanningAggregator` do today. Approving the parent triggers this one guided
|
||||||
|
flow, merging parent + all children in as few steps as possible. Because
|
||||||
|
children descend from the parent HEAD, the parent's commits are shared ancestors
|
||||||
|
and merge cleanly ahead of the children.
|
||||||
|
- The parent advances to `WaitingForReview` once **all** children are terminal —
|
||||||
|
counting `Done`, `Failed`, and `Cancelled`, so a failed child can't wedge the
|
||||||
|
parent forever. Failed/cancelled children are flagged on the review card.
|
||||||
|
|
||||||
|
Planning parents keep their existing behavior (parent → `Done` when its chain
|
||||||
|
finishes); they do not use `WaitingForChildren`.
|
||||||
|
|
||||||
|
## Consolidating the child subsystem
|
||||||
|
|
||||||
|
Today child handling is planning-coupled. Generalize:
|
||||||
|
|
||||||
|
- **`TaskRepository.CreateChildAsync`** — drop the `parent.PlanningPhase != None`
|
||||||
|
guard. A child can attach to any existing parent. (Planning callers are
|
||||||
|
unaffected; their parents have a planning phase.) The child sets
|
||||||
|
`ParentTaskId = parentId`; the caller decides `CreatedBy`.
|
||||||
|
- **Child-completion coordinator** — generalize planning's
|
||||||
|
`OnChildFinishedAsync` / `TryCompleteParentAsync` into a single component that,
|
||||||
|
on any child reaching a terminal state, checks the parent and applies a
|
||||||
|
**completion policy**:
|
||||||
|
- *planning parent* → finalize/Done (existing chain advancement stays in the
|
||||||
|
planning layer: unblock the next chained child).
|
||||||
|
- *improvement parent* (in `WaitingForChildren`, all children terminal) →
|
||||||
|
`WaitingForReview`.
|
||||||
|
- `TaskStateService` remains the sole writer of `Status` and owns the new
|
||||||
|
transitions (`SubmitForChildrenAsync`, the `WaitingForChildren → WaitingForReview`
|
||||||
|
advance).
|
||||||
|
|
||||||
|
## The offload tool
|
||||||
|
|
||||||
|
A narrow MCP tool exposed only to task runs (not the general external surface):
|
||||||
|
|
||||||
|
```
|
||||||
|
SuggestImprovement(title, description) → { childTaskId }
|
||||||
|
```
|
||||||
|
|
||||||
|
- The **server** stamps everything — the agent cannot choose the parent, the
|
||||||
|
status, or queue anything directly:
|
||||||
|
- `ParentTaskId = <calling task id>`
|
||||||
|
- `CreatedBy = <calling task id>` (unambiguous "agent-suggested improvement"
|
||||||
|
marker — distinct from `null` user/planning tasks and `"mcp"` external tasks)
|
||||||
|
- `Status = Idle`, same `ListId` as the parent.
|
||||||
|
- **One layer deep:** the tool rejects the call if the calling task already has a
|
||||||
|
`ParentTaskId` (a child cannot spawn children).
|
||||||
|
|
||||||
|
### Knowing the caller's identity
|
||||||
|
|
||||||
|
The always-on external `claudedo` MCP is shared and can't tell which task is
|
||||||
|
calling. So task runs get a **per-run MCP identity**, mirroring planning's
|
||||||
|
per-session token:
|
||||||
|
|
||||||
|
- `TaskRunner` mints a per-run token and writes a run-scoped `.mcp.json` (or
|
||||||
|
reuses the global server with a token header) so the offload tool resolves
|
||||||
|
token → calling task id server-side. A `TaskRunMcpContextAccessor` exposes the
|
||||||
|
current task id to the tool, the same way `PlanningMcpContextAccessor` does.
|
||||||
|
- This is the reliable path for both correct provenance and the one-layer-deep
|
||||||
|
guard — the id is never supplied by the model.
|
||||||
|
|
||||||
|
`system.md` gains a short instruction (from the prompt-overhaul spec):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Out-of-scope improvements
|
||||||
|
If you notice worthwhile work that is genuinely outside this task's scope
|
||||||
|
(a refactor, a follow-up, tech debt), do NOT do it here. File it with
|
||||||
|
SuggestImprovement(title, description) and stay focused on the task at hand.
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
- **Collapsible tree:** children group under their parent (by `ParentTaskId`).
|
||||||
|
Improvement-children are visually marked as agent-suggested (via
|
||||||
|
`CreatedBy == parentId`).
|
||||||
|
- **New status chip** for `WaitingForChildren` (e.g. amber "waiting on N
|
||||||
|
improvements") with its own color in `StatusColorConverter`.
|
||||||
|
- **Review card** for a parent in `WaitingForReview` lists child outcomes
|
||||||
|
(done/failed) and their rolled-up `CLAUDEDO_BLOCKED` problems, and drives the
|
||||||
|
shared tree-merge (parent + children) via the planning-style sequential flow
|
||||||
|
with conflict pause/continue/abort.
|
||||||
|
|
||||||
|
## Data / migration
|
||||||
|
|
||||||
|
- Add `WaitingForChildren` to the `TaskStatus` enum and its EF `ValueConverter`.
|
||||||
|
No new columns — `ParentTaskId` and `CreatedBy` already exist. No backfill
|
||||||
|
needed (no existing rows use the new value).
|
||||||
|
|
||||||
|
## Touch points
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Data/Models/TaskStatus` (enum) + `TaskEntityConfiguration` — new value.
|
||||||
|
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — generalize `CreateChildAsync`.
|
||||||
|
- `src/ClaudeDo.Worker/State/TaskStateService.cs` — `WaitingForChildren` transitions.
|
||||||
|
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — route to `WaitingForChildren` when
|
||||||
|
children exist; enqueue children on parent finish; mint per-run MCP token.
|
||||||
|
- New: child-completion coordinator (generalized from planning) + the offload tool
|
||||||
|
(e.g. `TaskRunMcpService.SuggestImprovement`) + `TaskRunMcpContextAccessor` +
|
||||||
|
token auth (mirrors `PlanningTokenAuth`).
|
||||||
|
- `src/ClaudeDo.Worker/Planning/*` — refactor planning to consume the shared
|
||||||
|
child-completion coordinator and the shared tree-merge; keep chain-specific
|
||||||
|
advancement local. Generalize `PlanningMergeOrchestrator` / `PlanningAggregator`
|
||||||
|
into a reusable tree-merge that also folds in the parent's own branch.
|
||||||
|
- Worktree setup (`TaskRunner` / `WorktreeManager`) — base an improvement-child's
|
||||||
|
worktree on the parent task's recorded worktree HEAD instead of the list base.
|
||||||
|
- UI — tree grouping, `WaitingForChildren` chip/color, parent review card with
|
||||||
|
child outcomes + rolled-up roadblocks + the merge flow.
|
||||||
|
- Tests — offload tool stamps parent/createdBy + rejects nested calls;
|
||||||
|
parent → `WaitingForChildren` → `WaitingForReview` lifecycle; child worktree
|
||||||
|
bases off parent HEAD; tree-merge folds parent + children; planning regression
|
||||||
|
(still reaches Done).
|
||||||
|
|
||||||
|
## Open questions for review
|
||||||
|
|
||||||
|
1. **Failed child:** parent still advances to `WaitingForReview` with the failure
|
||||||
|
flagged (default), vs. parent → `Failed` if any child failed.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Multi-level nesting (only one layer deep by design).
|
||||||
|
- Per-list "disable improvement offload" toggle (could come later; the tool is
|
||||||
|
always available to top-level runs for now).
|
||||||
|
- Changes to how planning sets up its sequential chain.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Debug Logging & Frontend↔Backend Traceability — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Approved (pending spec review)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make debug logging rich enough to diagnose problems across the UI↔Worker boundary, while keeping the installed (production) build near-silent. Verbosity is decided by **build configuration, detected at runtime** — no runtime knob, no config field, no `#if DEBUG`:
|
||||||
|
|
||||||
|
- **Debug build** (Rider run button) → verbose, console + file.
|
||||||
|
- **Release build** (installed app) → minimal, file only.
|
||||||
|
|
||||||
|
## Decisions (from brainstorming)
|
||||||
|
|
||||||
|
1. **Mechanism:** runtime build-config detection via the entry assembly's `DebuggableAttribute` (JIT optimizer disabled ⇒ Debug build). A single `BuildConfig.IsDebug` helper drives ordinary `if` branching — no `#if DEBUG` directives. Rider's run button builds `Debug`; the installer ships `-c Release`.
|
||||||
|
2. **Scope:** Worker **and** App/Ui. The desktop side currently has no log sink at all — UI/IPC failures vanish today.
|
||||||
|
3. **Release behavior:** all three log `Warning`+ to file (not silent — capture crashes). Worker drops from its current `Information` to `Warning`.
|
||||||
|
4. **One shared log file** across both processes, unified timeline.
|
||||||
|
5. **Correlation:** TaskId-based (option A). Enrich log lines with `TaskId` when one is in scope. No changes to the SignalR contract (`IWorkerClient`/`WorkerHub` untouched → test fakes untouched).
|
||||||
|
|
||||||
|
## Verbosity matrix
|
||||||
|
|
||||||
|
| Process | Debug build | Release build |
|
||||||
|
|---|---|---|
|
||||||
|
| Worker | `Debug` level, console + shared file | `Warning` level, shared file |
|
||||||
|
| App/Ui | `Debug` level, console + shared file | `Warning` level, shared file |
|
||||||
|
|
||||||
|
## Shared log file
|
||||||
|
|
||||||
|
- Single daily-rolling file: `~/.todo-app/logs/claudedo-.log` (Serilog appends the date).
|
||||||
|
- `shared: true` on both processes' file sinks → Serilog coordinates multi-process writes via a global mutex.
|
||||||
|
- `retainedFileCountLimit: 2`.
|
||||||
|
- Each line is tagged with a `Process` property (`"worker"` / `"app"`) so the two sides are distinguishable in the interleaved timeline.
|
||||||
|
|
||||||
|
> The existing `worker-.log` is replaced by `claudedo-.log`. Task-run NDJSON (`{taskId}_run{n}.ndjson`) and `daily-prep.log` are **out of scope** — they are data streams, not diagnostic logs, and stay exactly as they are.
|
||||||
|
|
||||||
|
## Output template
|
||||||
|
|
||||||
|
```
|
||||||
|
[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Process}/{SourceContext} [{TaskId}] {Message:lj}{NewLine}{Exception}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `{Process}` — `worker` or `app`.
|
||||||
|
- `{SourceContext}` — the `ILogger<T>` category (the logging class), so you see *which* component spoke.
|
||||||
|
- `{TaskId}` — the correlation key, defaulted to `-` when no task is in scope (see enricher below).
|
||||||
|
|
||||||
|
## Traceability (TaskId correlation)
|
||||||
|
|
||||||
|
Use Serilog's `LogContext` (`.Enrich.FromLogContext()` on both processes) plus a small default enricher so `TaskId` is always present (renders `-` when absent — avoids the raw `{TaskId}` token leaking into output).
|
||||||
|
|
||||||
|
Push the property at the entry points where a task is in scope; all nested `ILogger<T>` calls inherit it automatically:
|
||||||
|
|
||||||
|
- **Worker:** wrap per-task execution in `TaskRunner` (the run/continue entry) with `using (LogContext.PushProperty("TaskId", task.Id))`. This covers the bulk of backend activity (runner, state transitions, worktree, planning) for free.
|
||||||
|
- **App/Ui:** push `TaskId` in `WorkerClient` task-targeted calls (e.g. RunNow / Cancel / Continue / review actions) so the UI side of a task action carries the same key.
|
||||||
|
|
||||||
|
Result: grep one `TaskId` in `claudedo-.log` and read the full UI→Worker→UI story in timestamp order.
|
||||||
|
|
||||||
|
This adds **no parameters** to the SignalR surface — correlation rides on the existing `taskId` arguments already present in those calls.
|
||||||
|
|
||||||
|
## Implementation surface
|
||||||
|
|
||||||
|
A single shared helper keeps the two processes' Serilog setup from drifting.
|
||||||
|
|
||||||
|
- **New project:** `ClaudeDo.Logging` — a small library both `ClaudeDo.App` and `ClaudeDo.Worker` reference (keeps `ClaudeDo.Data` free of any Serilog dependency). Contains:
|
||||||
|
- `BuildConfig.IsDebug` — checks the entry assembly's `DebuggableAttribute` (`IsJITOptimizerDisabled` ⇒ Debug build). Cached static.
|
||||||
|
- The output template and the default-TaskId enricher.
|
||||||
|
- `ConfigureLogger(LoggerConfiguration, processTag, logRoot)` — applies level/sink choices by branching on `BuildConfig.IsDebug` (Debug ⇒ `Debug` level + console + file; Release ⇒ `Warning` level + file only). Both processes call it so level/template/retention stay in sync.
|
||||||
|
- **Worker `Program.cs:34`:** replace the inline `UseSerilog` body with a call into the shared helper (`processTag = "worker"`).
|
||||||
|
- **App `Program.cs`:** add Serilog packages; build a logger via the shared helper (`Process = "app"`) and register it with `sc.AddLogging(b => b.AddSerilog(logger, dispose: true))`. App currently registers **no** logging at all, so this also makes `ILogger<T>` injection actually work UI-side. Remove/keep `.LogToTrace()` as appropriate (Avalonia internal trace, separate concern — leave it).
|
||||||
|
- **App shutdown:** flush/close the logger (`Log.CloseAndFlush()` or dispose via the container's existing `finally`).
|
||||||
|
|
||||||
|
### Packages to add (App project)
|
||||||
|
|
||||||
|
- `Serilog.Extensions.Logging` (bridge `ILogger` → Serilog)
|
||||||
|
- `Serilog.Sinks.File`
|
||||||
|
- `Serilog.Sinks.Console`
|
||||||
|
- (Worker already has Serilog + File sink; add `Serilog.Sinks.Console` for the Debug console output.)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- This is logging wiring; per project policy, no tests that spawn the real Claude CLI and no heavy test scaffolding for log output.
|
||||||
|
- Light verification: a unit-level check that the default enricher yields `-` when no `TaskId` is pushed, and (if practical) that `ConfigureLogger` wires the expected sinks. `BuildConfig.IsDebug` reflects the test assembly's own build config, so it can't be flipped within one run — assert each branch by passing the level/flag explicitly rather than relying on the ambient value, or verify the Release path and smoke-test Debug manually from Rider.
|
||||||
|
- Manual smoke test (documented, not automated): run from Rider, confirm console + `claudedo-.log` show `Debug` lines with `Process`/`SourceContext`; run a task and confirm both `app` and `worker` lines share the same `[TaskId]`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Runtime/config log-level knob.
|
||||||
|
- Per-call correlation IDs for non-task flows (connect, config edits, prep) — TaskId-only for now; revisit if a non-task flow proves to be a black hole.
|
||||||
|
- Changes to task-run NDJSON capture or `daily-prep.log`.
|
||||||
|
- Any change to `IWorkerClient` / `WorkerHub` signatures.
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Inherited settings display, per-task overrides, and Turns
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Approved (design)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Config inheritance is three-tier (Task → List → Global app settings). Today the UI
|
||||||
|
only signals inheritance with a placeholder sentinel (`(inherit)` for tasks,
|
||||||
|
`(default)` for lists) and, for tasks, a faint "Effective if inherited: {value}"
|
||||||
|
hint under Model and Agent. Two gaps:
|
||||||
|
|
||||||
|
1. You can't see the *actual resolved value* an inherited field will use, nor where
|
||||||
|
it comes from (List vs Global).
|
||||||
|
2. **Max turns** is global-only (`AppSettingsEntity.DefaultMaxTurns` = 100). It is not
|
||||||
|
overridable per list or per task, unlike Model / SystemPrompt / AgentPath.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Show the real inherited value in-place, muted, with a **source-aware marker**
|
||||||
|
(`inherited · List` vs `inherited · Global`). Picking a value turns it into an
|
||||||
|
override; a reset affordance clears it back to inherited.
|
||||||
|
- Add **Turns** (max turns) as an overridable field at both List and Task levels,
|
||||||
|
inheriting from the global default. Numeric box; empty = inherit.
|
||||||
|
- Keep SystemPrompt as-is (it is additive, not override) but show what gets prepended.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No change to SystemPrompt merge semantics (stays additive/concatenated).
|
||||||
|
- No new global settings; `DefaultMaxTurns` already exists.
|
||||||
|
- No change to PermissionMode handling.
|
||||||
|
|
||||||
|
## Inheritance semantics (reference)
|
||||||
|
|
||||||
|
Resolved in `TaskRunner.BuildRunConfig` (~line 388):
|
||||||
|
|
||||||
|
| Field | Semantics | Resolution |
|
||||||
|
|--------------|------------|--------------------------------------------------------|
|
||||||
|
| Model | override | `task.Model ?? listConfig?.Model ?? global.DefaultModel` |
|
||||||
|
| AgentPath | override | `task.AgentPath ?? listConfig?.AgentPath` (no global) |
|
||||||
|
| MaxTurns | override | **new:** `task.MaxTurns ?? listConfig?.MaxTurns ?? global.DefaultMaxTurns` |
|
||||||
|
| SystemPrompt | additive | merged: global + list + task + agent (unchanged) |
|
||||||
|
|
||||||
|
Lists inherit only from Global (no tier above them), so a list's inherited marker is
|
||||||
|
always `inherited · Global`.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Data layer
|
||||||
|
|
||||||
|
- `ListConfigEntity`: add `int? MaxTurns`.
|
||||||
|
- `TaskEntity`: add `int? MaxTurns` (nullable override).
|
||||||
|
- EF Core migration adding `max_turns` column to `list_config` and `tasks`
|
||||||
|
(nullable, no default — null = inherit).
|
||||||
|
- `TaskRunner` BuildRunConfig: `MaxTurns: task.MaxTurns ?? listConfig?.MaxTurns ?? global.DefaultMaxTurns`.
|
||||||
|
`ClaudeRunConfig.MaxTurns` and `ClaudeArgsBuilder` already accept/emit `--max-turns`
|
||||||
|
when `> 0` — no change needed there.
|
||||||
|
- `ListRepository.SetConfigAsync` (upsert) and `TaskRepository.UpdateAgentSettingsAsync`
|
||||||
|
extend to carry `maxTurns`.
|
||||||
|
|
||||||
|
### 2. DTOs / transport
|
||||||
|
|
||||||
|
Add `int? MaxTurns` to (Worker + Ui copies kept in sync):
|
||||||
|
|
||||||
|
- `UpdateListConfigDto`, `ListConfigDto` (WorkerHub.cs + WorkerClient.cs)
|
||||||
|
- `UpdateTaskAgentSettingsDto` (WorkerHub.cs + WorkerClient.cs)
|
||||||
|
- `TaskConfigDto` (ConfigMcpTools.cs)
|
||||||
|
|
||||||
|
`WorkerHub.UpdateListConfig` / `UpdateTaskAgentSettings` persist the new field via the
|
||||||
|
repositories above. MCP `SetListConfig` / `SetTaskConfig` gain an optional `maxTurns`
|
||||||
|
parameter to keep the agent-facing API at parity with the UI.
|
||||||
|
|
||||||
|
### 3. Resolution helper (Ui)
|
||||||
|
|
||||||
|
A small helper that, given `(taskValue, listValue, globalValue)`, returns
|
||||||
|
`(effectiveValue, source)` where `source ∈ { Override, List, Global }`. Drives the
|
||||||
|
marker text and muted/normal styling for Model, Agent, and Turns so the logic isn't
|
||||||
|
duplicated per field or per editor. Lives in the Ui layer beside its consumers.
|
||||||
|
|
||||||
|
### 4. UI rendering — inherited marker (source-aware)
|
||||||
|
|
||||||
|
For **Model**, **Agent**, **Turns** in both `ListSettingsModalView` and the
|
||||||
|
DetailsIsland "Agent settings (overrides)" expander:
|
||||||
|
|
||||||
|
- Remove the `(inherit)` / `(default)` sentinel *row* from the control's item source.
|
||||||
|
- When no override is set: control shows the **resolved value muted/greyed** (dropdown
|
||||||
|
shows e.g. "sonnet" dimmed; Turns box shows e.g. "100" as a muted placeholder), and a
|
||||||
|
small badge beside the field label reads `inherited · List` or `inherited · Global`.
|
||||||
|
- On picking a value / typing a number: it becomes an override — text returns to normal
|
||||||
|
color, the badge flips to `override` (or hides), and a small **"↺ reset to inherited"**
|
||||||
|
affordance appears that clears the value back to null.
|
||||||
|
- List modal: source is always Global → badge reads `inherited · Global`; reset clears
|
||||||
|
to the global default.
|
||||||
|
- Turns: numeric box, empty = inherit (muted resolved number as placeholder); a typed
|
||||||
|
number is the override.
|
||||||
|
|
||||||
|
**Rendering approach:** a small reusable `InheritedFieldHeader` control (label + badge +
|
||||||
|
reset button), fed by the resolution helper's `source`, wraps each field. Keeps the three
|
||||||
|
fields consistent and avoids per-field XAML duplication. Badge / muted styling uses
|
||||||
|
existing design tokens. Visual polish pass is the user's.
|
||||||
|
|
||||||
|
### 5. SystemPrompt (stays plain)
|
||||||
|
|
||||||
|
SystemPrompt keeps its plain multi-line text box (additive, not override). Below it, a
|
||||||
|
small **read-only, collapsed-by-default** hint shows the inherited prompts that will be
|
||||||
|
prepended (global + list), labeled e.g. "Prepended automatically:". No marker, no reset —
|
||||||
|
it never replaces, only appends.
|
||||||
|
|
||||||
|
### 6. Localization
|
||||||
|
|
||||||
|
New keys in `locales/en.json` + `locales/de.json` (parity enforced by Localization.Tests):
|
||||||
|
marker text (`inherited · List`, `inherited · Global`, `override`), reset affordance
|
||||||
|
label, Turns field label, and the SystemPrompt "prepended automatically" hint. Retire the
|
||||||
|
now-unused `vm.details.effectiveIfInherited` key (and its German counterpart) if nothing
|
||||||
|
else references it.
|
||||||
|
|
||||||
|
## Affected files (indicative)
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Data/Models/ListConfigEntity.cs`, `TaskEntity.cs`
|
||||||
|
- `src/ClaudeDo.Data/Migrations/` (new migration)
|
||||||
|
- `src/ClaudeDo.Data/Repositories/ListRepository.cs`, `TaskRepository.cs`
|
||||||
|
- `src/ClaudeDo.Data/Configuration/` (column mapping for `max_turns`)
|
||||||
|
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
|
||||||
|
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
- `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` (+ `Interfaces/IWorkerClient.cs`)
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs` + view
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` + `DetailsIslandView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/Views/Controls/` (new `InheritedFieldHeader`)
|
||||||
|
- `src/ClaudeDo.Ui/` resolution helper
|
||||||
|
- `locales/en.json`, `locales/de.json`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Data: migration applies; `MaxTurns` round-trips through `ListRepository.SetConfigAsync`
|
||||||
|
and `TaskRepository.UpdateAgentSettingsAsync`.
|
||||||
|
- Worker: `BuildRunConfig` resolves MaxTurns via task → list → global precedence
|
||||||
|
(unit test on the resolution). Existing `ClaudeArgsBuilder` `--max-turns` behavior
|
||||||
|
unchanged.
|
||||||
|
- Ui: resolution helper returns correct `(value, source)` for each of the
|
||||||
|
override / list / global cases across Model, Agent, Turns.
|
||||||
|
- Localization: en/de key parity (existing Localization.Tests).
|
||||||
|
- Test fakes: update hand-rolled `IWorkerClient` fakes in both test projects for the new
|
||||||
|
DTO fields (per known gotcha).
|
||||||
|
- Visual verification of the marker / muted styling: flagged for the user (cannot be
|
||||||
|
asserted programmatically).
|
||||||
|
|
||||||
|
## Open risks
|
||||||
|
|
||||||
|
- DTO/ctor changes ripple into hand-rolled test fakes in Worker.Tests and Ui.Tests —
|
||||||
|
must be updated in the same change.
|
||||||
|
- Removing the sentinel row from dropdowns changes selection binding; ensure null/empty
|
||||||
|
override state is represented without a sentinel item (e.g. dropdown `SelectedItem`
|
||||||
|
null when inherited).
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# ClaudeDo distribution website — design
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Approved (design), ready for implementation planning
|
||||||
|
**Repo:** new standalone repo `claudedo-web` (not part of the ClaudeDo app solution)
|
||||||
|
**Domain:** `claudedo.kuns.dev` (Coolify on the user's VPS)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Give friends a public place to download ClaudeDo and learn what it does, without
|
||||||
|
sending them to the Gitea repo — so the source repo can be made more private. The
|
||||||
|
site also fronts the app's self-updater so the Gitea URL is never exposed in the
|
||||||
|
app or on the page.
|
||||||
|
|
||||||
|
## Goals / non-goals
|
||||||
|
|
||||||
|
**Goals**
|
||||||
|
- Public, no-auth landing page at `claudedo.kuns.dev` that matches the app's visual identity.
|
||||||
|
- A primary download (installer `.exe`) plus the portable `.zip` and checksums.
|
||||||
|
- A release proxy that (a) feeds the page the current version and (b) serves the
|
||||||
|
app's self-updater the same JSON shape Gitea returns, with download URLs rewritten
|
||||||
|
to route through `claudedo.kuns.dev` — hiding Gitea entirely.
|
||||||
|
|
||||||
|
**Non-goals**
|
||||||
|
- No docs site / getting-started page (the app ships an installer that handles setup).
|
||||||
|
- No changelog page (release notes already live on Gitea releases).
|
||||||
|
- No auth, accounts, analytics, or CMS.
|
||||||
|
- No CI/PR tooling for this repo beyond what Coolify needs to deploy.
|
||||||
|
|
||||||
|
## Access & distribution decisions
|
||||||
|
|
||||||
|
- **Access:** fully public. No password/login. Relies on the unadvertised URL.
|
||||||
|
- **Download source:** build-time fetch of the latest Gitea release for the displayed
|
||||||
|
version; actual download links route through the proxy and resolve the latest asset
|
||||||
|
at request time (so a stale page still downloads the current build).
|
||||||
|
- **Self-updater proxy:** in scope for v1 (not deferred).
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- **Nuxt 3** (Vue 3) — single framework, single repo, single Coolify deploy.
|
||||||
|
- **Nitro** server routes for the release proxy + asset streaming.
|
||||||
|
- **No DB, no auth, no secrets** (the `releases/ClaudeDo` repo is public).
|
||||||
|
- Fonts: **Inter Tight** (display/body) + **JetBrains Mono** (mono), self-hosted or via
|
||||||
|
Google Fonts. Design tokens ported from `docs/UI Rewrite/design_handoff_claudedo/Tokens.axaml`
|
||||||
|
and `styles.css` (moss/sage/peat palette, dark-first, 14px island radius, grain texture).
|
||||||
|
|
||||||
|
## Concept: "the page IS the app"
|
||||||
|
|
||||||
|
The landing page is a faithful, in-browser rendering of the ClaudeDo desktop — the
|
||||||
|
window chrome + the three islands — rather than a conventional marketing page. This
|
||||||
|
is the chosen direction (over a cinematic single-window scroll and a worklog feed).
|
||||||
|
|
||||||
|
### Layout — three islands on the app "desktop"
|
||||||
|
|
||||||
|
Desktop background = the app's layered moss gradients + 3px grain overlay. A centered
|
||||||
|
app `window` (titlebar + body) holds a 3-column island grid:
|
||||||
|
|
||||||
|
1. **Lists island (left)** — repurposed as page nav.
|
||||||
|
- Header "Lists" + a decorative search box (`Ctrl K` kbd chip).
|
||||||
|
- "Pages" group: Overview · Features (6) · How it works · Screenshots (3) · Download,
|
||||||
|
each with a colored swatch dot; active item gets the accent left-bar.
|
||||||
|
- Footer styled like the app's user footer: avatar, "For friends", `claudedo.kuns.dev`.
|
||||||
|
|
||||||
|
2. **Tasks island (middle)** — features rendered as **task cards**.
|
||||||
|
- Header: date eyebrow, big title (the hero line "Queue the work. Claude does it."),
|
||||||
|
a `running · review` badge, eye/gear icon buttons, and a subtitle.
|
||||||
|
- A decorative "Add a task…" row.
|
||||||
|
- Six **feature cards** (circle check — done cards filled; title; a status chip
|
||||||
|
`done`/`running`/`waiting for review`; a star). The features:
|
||||||
|
1. Isolated worktrees
|
||||||
|
2. The task queue
|
||||||
|
3. Review & merge
|
||||||
|
4. Live session log
|
||||||
|
5. Per-list & per-task config
|
||||||
|
6. Self-updating
|
||||||
|
- A "Ready" group with the final **"↓ Download ClaudeDo"** card.
|
||||||
|
|
||||||
|
3. **Detail island (right)** — faithful to the reworked Task-Detail island.
|
||||||
|
- **Source of truth for the detail visuals:** `docs/superpowers/specs/2026-06-04-task-detail-redesign-design.md`
|
||||||
|
(the app's in-progress rework). The website's detail pane must track that design.
|
||||||
|
- Three-zone stack:
|
||||||
|
- **Task header** — mono id (`#F01…`), title, trash/gear action icons.
|
||||||
|
- **DETAILS bar** — `DETAILS` eyebrow + `Edit` / copy / `⋯`, then a **markdown body**
|
||||||
|
(headings, paragraphs, inline `code`, ordered/unordered lists) describing the feature.
|
||||||
|
- **WorkConsole** docked at the bottom — traffic-light dots, `· N turns · +x −y`,
|
||||||
|
tabs **Output / Actions / Session**, and a `Created …` footer.
|
||||||
|
- Per-feature mapping:
|
||||||
|
- Feature panels: markdown writeup in DETAILS + a short relevant **Output** log.
|
||||||
|
- "Review & merge": opens on the **Actions** tab with `Merge target` + `Open Diff` /
|
||||||
|
`Approve & merge`.
|
||||||
|
- **Download**: DETAILS shows requirements (`.NET 8 Desktop Runtime`, `Claude CLI`,
|
||||||
|
`Git`); the **Actions** tab holds the install controls, with `Merge target`
|
||||||
|
repurposed as a **Build** selector and buttons `↓ Download installer` /
|
||||||
|
`Portable .zip` / `checksums.txt`.
|
||||||
|
|
||||||
|
4. **Statusbar (bottom)** — `● Online · claudedo.kuns.dev · private build`.
|
||||||
|
|
||||||
|
### Interaction
|
||||||
|
|
||||||
|
- Clicking a task card selects it (accent bar + card highlight) and swaps the active
|
||||||
|
detail panel; the WorkConsole tabs are clickable within a panel.
|
||||||
|
- **All panels are server-rendered and present in the DOM**, toggled by class — the
|
||||||
|
page is fully readable and downloadable **without JavaScript** (progressive
|
||||||
|
enhancement). Vue handles the selection state on the client.
|
||||||
|
|
||||||
|
### Responsive
|
||||||
|
|
||||||
|
- ≤ ~1100px: drop the Detail island; show Lists + Tasks.
|
||||||
|
- ≤ ~780px: single column — the Tasks list; tapping a feature pushes to a full-screen
|
||||||
|
Detail view (mirrors the app's narrow-window behavior) with a back affordance.
|
||||||
|
|
||||||
|
## Server: release proxy (Nitro)
|
||||||
|
|
||||||
|
The app's `ReleaseClient` (`src/ClaudeDo.Releases/ReleaseClient.cs`) calls
|
||||||
|
`{apiBase}/releases/latest` and reads `tag_name`, `name`, and
|
||||||
|
`assets[].browser_download_url`; `DownloadAsync` GETs an asset URL directly.
|
||||||
|
|
||||||
|
- **`GET /api/releases/latest`** — fetches `https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest`,
|
||||||
|
returns the **same JSON shape**, but every `assets[].browser_download_url` is rewritten
|
||||||
|
from the Gitea URL to `https://claudedo.kuns.dev/api/download/<encoded-asset-path>`.
|
||||||
|
Cached briefly (e.g. 5 min) server-side.
|
||||||
|
- **`GET /api/download/[...path]`** — reconstructs the Gitea asset URL from the path and
|
||||||
|
**streams** the binary back (no redirect to Gitea, so the URL stays hidden). Sets
|
||||||
|
appropriate `Content-Type`/`Content-Disposition`.
|
||||||
|
- **`server/utils/gitea.ts`** — shared base URL (`GITEA_API`, `REPO` from env), fetch
|
||||||
|
helper, and the URL-rewrite/asset-path round-trip.
|
||||||
|
- The page's download buttons point at the same `/api/download/...` routes (with a
|
||||||
|
stable "latest installer" path), so links never go stale between deploys.
|
||||||
|
|
||||||
|
### App-side coordinating change (separate, in the ClaudeDo repo)
|
||||||
|
|
||||||
|
Point `ReleaseClient`'s `apiBase` at `https://claudedo.kuns.dev/api` instead of the
|
||||||
|
Gitea default (one-line DI change where `ReleaseClient`/`UpdateCheckService` are
|
||||||
|
constructed). Tracked as a follow-up; not part of the `claudedo-web` repo. The proxy
|
||||||
|
path (`/api/releases/latest`) is chosen to match the existing
|
||||||
|
`{apiBase}/releases/latest` call so the parser is untouched.
|
||||||
|
|
||||||
|
## Content / assets
|
||||||
|
|
||||||
|
- **Screenshots (provided):** main 3-column view (hero), diff review modal, worktrees
|
||||||
|
panel. Stored in `public/screenshots/`. Placeholders sized for them until dropped in.
|
||||||
|
- Copy: hero "Queue the work. Claude does it."; six feature writeups as above (final
|
||||||
|
wording during implementation).
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- **Build-time release fetch fails:** render the page with a last-known/placeholder
|
||||||
|
version label; download buttons still work because they resolve via the runtime
|
||||||
|
proxy route.
|
||||||
|
- **Proxy `/api/releases/latest` upstream failure:** return a 502/`null`-equivalent the
|
||||||
|
way Gitea would on miss; the app's `UpdateCheckService` already treats null/exception
|
||||||
|
as `CheckFailed` and degrades gracefully.
|
||||||
|
- **`/api/download` upstream failure:** surface a 502; the button shows an error state.
|
||||||
|
- No retries beyond a single upstream attempt for v1 (low traffic, friends-only).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Vitest** unit tests for `server/utils/gitea.ts`: URL rewrite (Gitea → proxy) and the
|
||||||
|
asset-path round-trip (proxy path → Gitea URL), and release-JSON shape preservation.
|
||||||
|
- A light component smoke test that the page renders the islands and the download
|
||||||
|
controls without JS errors.
|
||||||
|
- No real-network/Gitea calls in tests — mock the upstream fetch.
|
||||||
|
|
||||||
|
## Deployment (Coolify)
|
||||||
|
|
||||||
|
- **Dockerfile**: `node:20-alpine` build → `nuxt build` → run `.output/server/index.mjs`.
|
||||||
|
- Coolify app bound to `claudedo.kuns.dev` with TLS via its reverse proxy.
|
||||||
|
- Env: `GITEA_API` (default `https://git.kuns.dev/api/v1`), `REPO` (`releases/ClaudeDo`),
|
||||||
|
`PUBLIC_BASE_URL` (`https://claudedo.kuns.dev`) for URL rewriting.
|
||||||
|
- Deploy on push to `main`; re-deploy (or a periodic rebuild) refreshes the displayed
|
||||||
|
version. No PR/CI tooling beyond Coolify's build.
|
||||||
|
|
||||||
|
## Open risk
|
||||||
|
|
||||||
|
- The reworked Detail island in the app is still in flux. The website's detail pane
|
||||||
|
must be kept in sync with `2026-06-04-task-detail-redesign-design.md`; expect a
|
||||||
|
visual-polish pass once that rework lands.
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
```
|
||||||
|
claudedo-web/
|
||||||
|
├── nuxt.config.ts
|
||||||
|
├── app.vue
|
||||||
|
├── pages/index.vue # the one landing page
|
||||||
|
├── components/
|
||||||
|
│ ├── AppWindow.vue # window chrome + statusbar
|
||||||
|
│ ├── ListsIsland.vue # page nav
|
||||||
|
│ ├── TasksIsland.vue # feature cards + download card
|
||||||
|
│ ├── DetailIsland.vue # three-zone detail (header / DETAILS md / WorkConsole)
|
||||||
|
│ ├── WorkConsole.vue # tabs: Output / Actions / Session
|
||||||
|
│ └── content/ # per-feature markdown/blurbs + download panel
|
||||||
|
├── server/
|
||||||
|
│ ├── api/releases/latest.get.ts
|
||||||
|
│ ├── api/download/[...path].get.ts
|
||||||
|
│ └── utils/gitea.ts
|
||||||
|
├── assets/css/tokens.css # palette + type ported from Tokens.axaml/styles.css
|
||||||
|
├── public/screenshots/ # 3 PNGs
|
||||||
|
└── Dockerfile
|
||||||
|
```
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Refine Task — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Approved (pending spec review)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a one-click **Refine Task** action to a task card. Clicking it spawns a
|
||||||
|
headless Claude session that reads the task (and the repo), rewrites the task's
|
||||||
|
description to be clearer and runnable autonomously, and — where it helps —
|
||||||
|
breaks the work into subtasks. The user then reviews/hand-edits the result and
|
||||||
|
queues the task manually.
|
||||||
|
|
||||||
|
This is **not** an interactive terminal session. It is a fire-and-forget
|
||||||
|
headless run, structurally similar to the existing daily-prep ("Prime Claude")
|
||||||
|
flow (`PrimeRunner`), not the interactive planning flow.
|
||||||
|
|
||||||
|
## Non-goals / scope
|
||||||
|
|
||||||
|
- No new task status. The task stays `Idle` throughout; refine only mutates the
|
||||||
|
task's `Title`/`Description` and its subtasks.
|
||||||
|
- No worktree, no interactive terminal, no auto-queue.
|
||||||
|
- No per-task refine config (model, turns) — uses the worker's defaults.
|
||||||
|
- Refine does not edit repository files; repo access is read-only.
|
||||||
|
|
||||||
|
## User flow
|
||||||
|
|
||||||
|
1. User clicks the refine icon on an `Idle` task's card.
|
||||||
|
2. UI calls `WorkerHub.RefineTask(taskId)` → `RefineRunner`.
|
||||||
|
3. `RefineRunner` spawns `claude -p` headless in the list's working directory,
|
||||||
|
seeded with a fixed refine prompt + the task's title/description/current
|
||||||
|
subtasks + the task id.
|
||||||
|
4. Claude reads the repo (read-only), then calls:
|
||||||
|
- `mcp__claudedo__update_task` to improve title/description, and
|
||||||
|
- `mcp__claudedo__add_subtask` to add steps where useful.
|
||||||
|
Each MCP call broadcasts `TaskUpdated`, so the description and Steps card
|
||||||
|
update live in the UI.
|
||||||
|
5. Run finishes; the card's refine button returns to its idle state. User
|
||||||
|
reviews, optionally hand-edits the description/steps, then queues manually.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Worker — `RefineRunner`
|
||||||
|
|
||||||
|
- New `Worker/Refine/RefineRunner.cs` implementing `IRefineRunner`
|
||||||
|
(`Worker/Refine/Interfaces/IRefineRunner.cs`). Modeled on `PrimeRunner`.
|
||||||
|
- **Concurrency / single-flight:** an in-flight `HashSet<string>` of task ids
|
||||||
|
guarded by a lock (or `SemaphoreSlim`), so the *same* task cannot refine
|
||||||
|
twice concurrently, but different tasks may refine in parallel. A second
|
||||||
|
click on an already-refining task is a no-op.
|
||||||
|
- **Guards:** only runs when `task.Status == Idle`. Resolves the list's working
|
||||||
|
directory. If the list has **no valid working dir**, fall back to a sandbox
|
||||||
|
directory and run text-only (drop `Read`/`Grep`/`Glob` from the allowlist).
|
||||||
|
- **CLI invocation** (relies on the globally-registered `claudedo` MCP, like
|
||||||
|
daily-prep — no `--mcp-config`):
|
||||||
|
```
|
||||||
|
claude -p --output-format stream-json --verbose
|
||||||
|
--permission-mode acceptEdits
|
||||||
|
--max-turns <N>
|
||||||
|
--allowedTools mcp__claudedo__get_task,mcp__claudedo__update_task,mcp__claudedo__add_subtask,Read,Grep,Glob
|
||||||
|
```
|
||||||
|
`Edit`/`Write`/`Bash` are deliberately **not** whitelisted, so the run is
|
||||||
|
read-only on the repo even under `acceptEdits`. (Chosen over `plan` mode to
|
||||||
|
avoid the headless "exit plan mode to act" friction; the allowlist is the
|
||||||
|
real read-only gate.)
|
||||||
|
- **Logging:** stream stdout to a per-run log at
|
||||||
|
`logs/refine-<taskId[:8]>.log`, truncated at the start of each run.
|
||||||
|
|
||||||
|
### Prompt — `PromptKind.Refine`
|
||||||
|
|
||||||
|
- Add `Refine` to the `PromptKind` enum in `PromptFiles.cs`, file
|
||||||
|
`prompts/refine.md`, with a bundled default.
|
||||||
|
- Default prompt instructs: refine one ClaudeDo task so it is ready to run
|
||||||
|
autonomously; ground the description in the actual code (read-only); keep
|
||||||
|
scope tight (no scope creep into adjacent work); add steps as subtasks only
|
||||||
|
when they genuinely help; use only `get_task`, `update_task`, `add_subtask`
|
||||||
|
and the read-only tools; never edit files.
|
||||||
|
- Rendered via `PromptFiles.Render` with `{taskId}`, `{title}`,
|
||||||
|
`{description}`, and the current subtask list seeded into the prompt so the
|
||||||
|
agent knows which steps already exist.
|
||||||
|
|
||||||
|
### MCP tool — `add_subtask`
|
||||||
|
|
||||||
|
- New `[McpServerTool]` on `ExternalMcpService` (part of the global `claudedo`
|
||||||
|
MCP), signature `add_subtask(taskId, title, orderNum?)`.
|
||||||
|
- Creates a `SubtaskEntity` via `SubtaskRepository`; `orderNum` defaults to
|
||||||
|
append-at-end (max existing + 1). Refuses if the task is `Running`.
|
||||||
|
Broadcasts `TaskUpdated`.
|
||||||
|
- **Append semantics, not replace:** the current subtasks are already in the
|
||||||
|
prompt, so the agent only adds missing steps; re-running refine will not
|
||||||
|
silently wipe steps the user hand-edited.
|
||||||
|
- `update_task` already exists (title/description/commitType) and is reused
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
### UI — button, icon, feedback
|
||||||
|
|
||||||
|
- **Icon:** add the supplied SVG as an `Icon.Refine` `StreamGeometry` in
|
||||||
|
`IslandStyles.axaml`, rendered as a **stroked `Path`** (`plan-icon` style,
|
||||||
|
fill none) — it is line art, so per the PathIcon-fills-geometry gotcha it
|
||||||
|
must be stroked, not filled.
|
||||||
|
- **Button:** a new `icon-btn` in `TaskRowView.axaml` near the star button,
|
||||||
|
visible only when the task is `Idle`. Bound to a new `RefineTaskCommand` on
|
||||||
|
`TasksIslandViewModel`.
|
||||||
|
- **Feedback:** new broadcaster events `RefineStarted(taskId)` /
|
||||||
|
`RefineFinished(taskId, ok, error?)` drive an `IsRefining` flag on
|
||||||
|
`TaskRowViewModel`; the button shows a busy/disabled state while running. The
|
||||||
|
description and Steps card update live via the existing `TaskUpdated` events
|
||||||
|
fired by the MCP calls.
|
||||||
|
- Wire `RefineTask` through `IWorkerClient` / `WorkerClient`, the `WorkerHub`
|
||||||
|
method, and update the hand-rolled test fakes in both test projects.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `add_subtask`: creates the row, appends order correctly, refuses when
|
||||||
|
`Running`, broadcasts `TaskUpdated`.
|
||||||
|
- Refine prompt builder and CLI-args builder produce the expected prompt/flags
|
||||||
|
(including the text-only fallback when no working dir).
|
||||||
|
- `RefineRunner` guards: `Idle`-only, per-task single-flight no-op on a second
|
||||||
|
concurrent call.
|
||||||
|
- **No test spawns the real `claude` CLI** (project rule). The end-to-end run
|
||||||
|
is a manual smoke step.
|
||||||
|
|
||||||
|
## Open implementation calls (decided)
|
||||||
|
|
||||||
|
- **Permission mode:** `acceptEdits` + restricted allowlist for read-only
|
||||||
|
(rather than `plan` mode).
|
||||||
|
- **`add_subtask`:** append-only (rather than replace-all).
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Task Detail Island Redesign — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Approved (design), pending implementation
|
||||||
|
**Author:** brainstormed with user via visual companion
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The Detail island (`DetailsIslandView`, 413 lines) grew into one long scrolling
|
||||||
|
column as features piled on. The user has to scroll constantly. Specific pains
|
||||||
|
(confirmed by the user):
|
||||||
|
|
||||||
|
- **Everything is always stacked** — Steps, Description, Terminal, and several
|
||||||
|
conditional sections share one scroll column with no way to hide/fold.
|
||||||
|
- **Duplicated info** — `model` shows in the gear flyout *and* the agent strip;
|
||||||
|
the branch line shows in the agent strip *and* as the terminal label.
|
||||||
|
- **Agent strip is a heavy 5-row block** pinned near the bottom even when idle.
|
||||||
|
- **Steps + Description take a lot of room** before the action controls.
|
||||||
|
|
||||||
|
The terminal staying prominent is *fine* — not a pain point.
|
||||||
|
|
||||||
|
## Solution overview
|
||||||
|
|
||||||
|
Replace the linear body with a **fixed-region layout** built from **3 new
|
||||||
|
self-contained components**, plus a roadblock band. Top region (header + details
|
||||||
|
card) stays put; the work console is pinned to the lower third.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ TaskHeaderBar (separated title) │ #T42 · title · 🗑/💀 · ⚙
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ DescriptionStepsCard │ card; text ⇄ steps toggle icon
|
||||||
|
│ (Preview = what Claude gets) │ copy · preview/edit
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Roadblock band (only when failed) │ ⚠ message · Continue · Reset&Retry
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ WorkConsole (pinned, terminal) │ ●●● · model·turns·diff
|
||||||
|
│ tabs: Output | Actions | Session │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## The 3 components
|
||||||
|
|
||||||
|
Each is a standalone `UserControl` + dedicated `ViewModel` with **design-time
|
||||||
|
sample data** so it renders fully in the Avalonia previewer in isolation. Built
|
||||||
|
in separate worktrees; **none touch `DetailsIslandView.axaml` or
|
||||||
|
`DetailsIslandViewModel.cs`** (that is the wiring session). All visuals use
|
||||||
|
**only** the existing design tokens (`Design/Tokens.axaml`) and style classes
|
||||||
|
(`Design/IslandStyles.axaml`) — no hardcoded colors/sizes.
|
||||||
|
|
||||||
|
New folder: `src/ClaudeDo.Ui/Views/Islands/Detail/`
|
||||||
|
New VMs: `src/ClaudeDo.Ui/ViewModels/Islands/Detail/`
|
||||||
|
|
||||||
|
### 1. TaskHeaderBar
|
||||||
|
|
||||||
|
- **Layout:** one row — `#T42` id badge (mono `meta`, copyable) · editable title
|
||||||
|
`TextBox` (transparent, `FontSizeTaskTitle`, wraps) · **trash/skull button** ·
|
||||||
|
⚙ gear button with the agent-settings flyout.
|
||||||
|
- **Trash → Skull:** when **not** running show `Icon.Trash` (delete task,
|
||||||
|
`BloodBrush`); when **running** show a **skull** glyph (kill session). One
|
||||||
|
button, swaps icon + command on running state. Skull is a *new* filled
|
||||||
|
geometry to add to `IslandStyles.axaml` resources (`Icon.Skull`).
|
||||||
|
- **No done circle. No star** (the star lives on the task card/row already).
|
||||||
|
- **Gear flyout:** keep the existing agent-settings content verbatim — Model
|
||||||
|
combo + `InheritedBadge` + reset; Max Turns `NumericUpDown` + badge + reset;
|
||||||
|
System Prompt `TextBox` + "prepended" hint; Agent File combo + badge + reset.
|
||||||
|
Disabled while running (`IsAgentSectionEnabled`).
|
||||||
|
- **Existing bindings reused:** `TaskIdBadge`, `EditableTitle`, `DeleteTaskCommand`,
|
||||||
|
`StopCommand`, `IsRunning`, `IsAgentSectionEnabled`, all the agent-settings
|
||||||
|
members (`TaskModelOptions`/`TaskModelSelection`/`ModelBadge`/
|
||||||
|
`ResetTaskModelCommand`/`TaskMaxTurns`/`TurnsBadge`/`ResetTaskTurnsCommand`/
|
||||||
|
`TaskSystemPrompt`/`EffectiveSystemPromptHint`/`TaskAgentOptions`/
|
||||||
|
`TaskSelectedAgent`/`AgentBadge`/`ResetTaskAgentCommand`).
|
||||||
|
|
||||||
|
### 2. DescriptionStepsCard
|
||||||
|
|
||||||
|
A `Border.island`-style card. The single explicitly-requested "separate
|
||||||
|
component." Top-right **toggle icon** switches the card between **Description**
|
||||||
|
and **Steps** views; the icon shows the *other* mode (in Description view → steps
|
||||||
|
icon `Icon.MoreHorizontal`/list glyph; in Steps view → text glyph).
|
||||||
|
|
||||||
|
- **Header row:** small `section-label` ("DETAILS" / "STEPS") · spacer · **Copy**
|
||||||
|
icon button (`Icon.Copy`) · **Preview/Edit** toggle button (Description view
|
||||||
|
only) · **toggle icon** (top-right).
|
||||||
|
- **Description view:**
|
||||||
|
- *Preview mode* = renders **what Claude gets** via `MarkdownView`: the
|
||||||
|
canonical composed text (Title + Description + open steps — see below).
|
||||||
|
- *Edit mode* = raw description `TextBox` (mono, `Surface2Brush`, multiline).
|
||||||
|
- **Steps view:** add-step input (Enter to add) + list of step rows (check
|
||||||
|
circle `Ellipse.task-check` + inline-editable title, `subtask-row` style).
|
||||||
|
- **Copy** copies the **formatted** version (Title + Description + open steps),
|
||||||
|
nothing else, to the clipboard.
|
||||||
|
- **Existing bindings reused (when wired):** `EditableDescription`,
|
||||||
|
`IsEditingDescription`/`ToggleEditDescriptionCommand`, `Subtasks`,
|
||||||
|
`NewSubtaskTitle`/`AddSubtaskCommand`, `ToggleSubtaskDoneCommand`,
|
||||||
|
`CommitSubtaskEditCommand`.
|
||||||
|
- **New members (defined on the component VM now, lifted into
|
||||||
|
`DetailsIslandViewModel` at wiring):** `IsStepsView` + `ToggleCardViewCommand`;
|
||||||
|
`ComposedPreview` (string, the canonical format); `CopyFormattedCommand`.
|
||||||
|
|
||||||
|
### 3. WorkConsole
|
||||||
|
|
||||||
|
Terminal-styled card (`Border.terminal`) pinned to the lower third.
|
||||||
|
|
||||||
|
- **Title bar:** three cosmetic traffic-light dots (`Ellipse.dot-red`,
|
||||||
|
`dot-yellow`, `dot-green`) on the left; centered/!right small **info header**:
|
||||||
|
`model · {turns} turns · +adds −dels` (mono `meta`; `diff-add`/`diff-del`
|
||||||
|
classes for the numbers). **No branch line.** LIVE/DONE/FAILED chip
|
||||||
|
(`live-chip`) on the right.
|
||||||
|
- **Tab strip:** `Output` | `Actions` | `Session`.
|
||||||
|
- **Output** — the live log. Reuse `SessionTerminalView` (`Entries`, `Label`,
|
||||||
|
`IsRunning`, `IsDone`, `IsFailed`) for the body, *or* the same
|
||||||
|
timestamp+`SelectableTextBlock` row template.
|
||||||
|
- **Actions** — worktree management: merge-target `ComboBox`, **Open Diff**,
|
||||||
|
**Worktree**, **Merge** (+ planning **Merge All Subtasks** when planning
|
||||||
|
parent). Bindings: `MergeTargetBranches`/`SelectedMergeTarget`,
|
||||||
|
`OpenDiffCommand`, `OpenWorktreeCommand`, `MergeAllCommand`/`CanMergeAll`/
|
||||||
|
`MergeAllDisabledReason`/`MergeAllError`, `ReviewCombinedDiffCommand`.
|
||||||
|
- **Session** — review + outcomes: feedback `TextBox` + Approve/Reject/Park/
|
||||||
|
Cancel (`ReviewFeedback`, `ApproveReviewCommand`, `RejectReviewCommand`,
|
||||||
|
`ParkReviewCommand`, `CancelReviewCommand`, shown when `IsWaitingForReview`)
|
||||||
|
and the child-outcomes list (`ChildOutcomes`, `HasChildOutcomes`).
|
||||||
|
- **Roadblock band** (above the tabs, inside or just above the card): visible on
|
||||||
|
`IsFailed`/`IsCancelled`; shows a warning (`Icon.Warning`, `BloodBrush`) and
|
||||||
|
**Continue** (`ContinueCommand`, `ShowContinue`) + **Reset & Retry**
|
||||||
|
(`ResetAndRetryCommand`, `ShowResetAndRetry`).
|
||||||
|
- **Info-header bindings:** `Model`, `Turns`, `DiffAdditions`, `DiffDeletions`,
|
||||||
|
`IsRunning`/`IsDone`/`IsFailed`.
|
||||||
|
|
||||||
|
## Combined Description + Steps behavior
|
||||||
|
|
||||||
|
Steps are part of the description. When the task runs, the **effective prompt =
|
||||||
|
Title + Description + only the OPEN steps**. Resolved steps are dropped.
|
||||||
|
|
||||||
|
**Canonical composed format** (shared by the Worker prompt, the card's Preview,
|
||||||
|
and Copy):
|
||||||
|
|
||||||
|
```
|
||||||
|
<Title>
|
||||||
|
|
||||||
|
<Description>
|
||||||
|
|
||||||
|
## Sub-Tasks
|
||||||
|
- [ ] <open step 1>
|
||||||
|
- [ ] <open step 2>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Omit the `## Sub-Tasks` section entirely when no open steps remain.
|
||||||
|
- Omit the description paragraph when description is empty.
|
||||||
|
|
||||||
|
**Worker change (wiring session, by Claude):** `TaskRunner.cs:104-113` currently
|
||||||
|
appends *all* subtasks with `[x]`/`[ ]`. Change to append **only incomplete**
|
||||||
|
subtasks as `- [ ]` lines (drop completed). Factor the format into a shared
|
||||||
|
`TaskPromptComposer` in `ClaudeDo.Data` (referenced by both Worker and UI) so the
|
||||||
|
card's Preview and the real prompt never diverge.
|
||||||
|
|
||||||
|
## Color / token guidelines (mandatory)
|
||||||
|
|
||||||
|
- Backgrounds: `IslandBackgroundBrush`, `Surface2Brush`, `Surface3Brush`,
|
||||||
|
`DeepBrush`, `VoidBrush` (terminal). Borders: `LineBrush`/`LineBrightBrush`,
|
||||||
|
`HairlineOverlayBrush`. Text: `TextBrush`/`TextDimBrush`/`TextMuteBrush`/
|
||||||
|
`TextFaintBrush`. Accent: `AccentBrush`/`AccentDimBrush`. Status: blood/peat/
|
||||||
|
moss/sage + the `*TintBrush` pairs.
|
||||||
|
- Radii: `IslandCornerRadius` (14), `ButtonCornerRadius` (6), `InputCornerRadius`
|
||||||
|
(8). Spacing: `SpaceXs..Space2Xl`. Fonts: `SansFont`, `MonoFont`; sizes
|
||||||
|
`FontSizeMono`/`FontSizeBody`/`FontSizeTaskTitle`.
|
||||||
|
- Reuse style classes: `island`, `island-header`, `chip`, `btn`/`btn accent`/
|
||||||
|
`primary`/`danger`, `icon-btn`, `flat`, `terminal`, `dot-red/yellow/green`,
|
||||||
|
`live-chip`, `task-check`, `subtask-row`, `section-label`, `field-label`,
|
||||||
|
`meta`, `diff-add`/`diff-del`, `diff-meter-*`.
|
||||||
|
- **No inline hex, no magic numbers** where a token exists. `PathIcon` fills
|
||||||
|
geometry — line-art must be filled or stroked via `Path`.
|
||||||
|
|
||||||
|
## Build / isolation strategy
|
||||||
|
|
||||||
|
1. Three ClaudeDo tasks (list "Claude do", repo `C:\Private\ClaudeDo`), one per
|
||||||
|
component, run sequentially in their own worktrees.
|
||||||
|
2. Each delivers: `Detail/<Name>.axaml` + `.axaml.cs` + `Detail/<Name>ViewModel.cs`
|
||||||
|
with design-time sample data; the `ClaudeDo.Ui` project **builds green**
|
||||||
|
(`dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release`).
|
||||||
|
3. Components are visual-only against sample data. Real `DetailsIslandViewModel`
|
||||||
|
binding + the Worker steps→prompt change happen in the **wiring session**
|
||||||
|
(this Claude session, done while the build tasks run).
|
||||||
|
|
||||||
|
## Wiring plan (this session)
|
||||||
|
|
||||||
|
- Implement `TaskPromptComposer` + the `TaskRunner` open-steps change + a unit
|
||||||
|
test in `ClaudeDo.Worker.Tests`/`Data.Tests`.
|
||||||
|
- After the 3 components land: host them in `DetailsIslandView` (header top,
|
||||||
|
card below, roadblock band, work console pinned bottom), lift the new card VM
|
||||||
|
members into `DetailsIslandViewModel`, repoint `x:DataType`, delete the
|
||||||
|
superseded inline sections + `AgentStripView` usage. Update locale parity and
|
||||||
|
the test fakes.
|
||||||
|
|
||||||
|
## Monitoring loop (this session)
|
||||||
|
|
||||||
|
While the build tasks run: poll each via `get_task` / `get_task_log` /
|
||||||
|
`get_task_diff`, summarize progress and anything a session got stuck on, and if a
|
||||||
|
session is blocked on something missing, add a small follow-up task to the
|
||||||
|
"Claude do" list.
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Git Tab / Merge & Review Rework — Design
|
||||||
|
|
||||||
|
Date: 2026-06-05
|
||||||
|
Status: Approved
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make handling merges and reviews as simple as possible in the Terminal component's
|
||||||
|
Git tab, and rework the diff viewers and worktree modals along the way. The work is
|
||||||
|
split into three layers built across separate sessions, with a shared foundation that
|
||||||
|
is built and pushed first so the parallel sessions branch from frozen contracts.
|
||||||
|
|
||||||
|
The user mostly trusts task output but wants the diff one click away for important
|
||||||
|
work, and wants to land several independently-queued worktrees without per-task
|
||||||
|
hopping or hand-resolving conflicts in an external editor.
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
- **Layer A — Review/merge cockpit** (this session). Single-task review + merge UX in
|
||||||
|
the Git tab; consolidate the four diff renderers into one `DiffView`.
|
||||||
|
- **Layer B — Multi-worktree merge cockpit** (parallel session). Batch-merge N
|
||||||
|
worktrees into one target, skip-and-continue, conflicts collected for resolution.
|
||||||
|
- **Layer C — Inline conflict resolver** (parallel session). VSCode-style inline hunk
|
||||||
|
resolver plus the worker-side conflict plumbing it needs.
|
||||||
|
|
||||||
|
They stack: A defines the single-task flow, B reuses it for many tasks, both funnel
|
||||||
|
conflicts into C.
|
||||||
|
|
||||||
|
## Shared foundation (built & pushed this session, before B/C branch)
|
||||||
|
|
||||||
|
Everything B and C depend on lands first on `main`. B and C branch from that commit.
|
||||||
|
|
||||||
|
### 1. One diff model + one `DiffView` control
|
||||||
|
|
||||||
|
Today there are four diff renderers and two parallel diff models:
|
||||||
|
|
||||||
|
- `DiffLinesView.axaml` (used by `DiffModalView`)
|
||||||
|
- the inline diff `ItemsControl` in `WorktreeModalView.axaml`
|
||||||
|
- `PlanningDiffView.axaml`
|
||||||
|
- their backing models: `DiffFileViewModel`/`DiffLineViewModel` (+ `UnifiedDiffParser`)
|
||||||
|
vs `WorktreeNodeViewModel`/`WorktreeDiffLineViewModel`
|
||||||
|
|
||||||
|
Collapse into a single canonical diff model + parser + a `DiffView` UserControl. All
|
||||||
|
diff rendering across the app goes through `DiffView`.
|
||||||
|
|
||||||
|
- Model: `DiffFileViewModel { Path, AddCount, DelCount, Lines }`,
|
||||||
|
`DiffLineViewModel { OldNo, NewNo, Kind (Add|Del|Ctx|File|Hunk), Text }`.
|
||||||
|
- Parser: one static `UnifiedDiffParser.Parse(rawUnifiedDiff)` returning the model.
|
||||||
|
- `DiffView` exposes a `Files` styled property (file list + selected-file lines), or a
|
||||||
|
simpler `Lines` property for single-file use — Layer A decides the exact surface
|
||||||
|
while building it, but the type names above are frozen so B and C can bind to them.
|
||||||
|
|
||||||
|
### 2. Frozen worker conflict contract
|
||||||
|
|
||||||
|
Added to `IWorkerClient` (and `WorkerClient` with stub bodies that throw
|
||||||
|
`NotSupportedException`) plus new DTOs, so A and B compile against the interface while
|
||||||
|
C provides the real worker-side implementation.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// IWorkerClient additions (signatures frozen this session)
|
||||||
|
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
||||||
|
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
||||||
|
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
||||||
|
Task<MergeResultDto> ContinueMergeAsync(string taskId);
|
||||||
|
Task AbortMergeAsync(string taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `StartConflictMergeAsync` performs the merge with `leaveConflictsInTree: true` (the
|
||||||
|
worker already supports this flag — used today by the planning orchestrator) and
|
||||||
|
returns `MergeResultDto` with `Status="conflict"` and the conflict file list, leaving
|
||||||
|
`.git/MERGE_HEAD` in place in the list's `WorkingDir`.
|
||||||
|
- `GetMergeConflictsAsync` returns each conflicted file with ours/theirs/base content,
|
||||||
|
read via `git show :2:<path>` (ours), `:3:<path>` (theirs), `:1:<path>` (base).
|
||||||
|
- `WriteConflictResolutionAsync` writes resolved content to the file in `WorkingDir`
|
||||||
|
and `git add`s it.
|
||||||
|
- `ContinueMergeAsync` wraps the existing `TaskMergeService.ContinueMergeAsync`
|
||||||
|
(`git add -A` → re-check `git diff --name-only --diff-filter=U` → `git commit`).
|
||||||
|
- `AbortMergeAsync` wraps the existing `TaskMergeService.AbortMergeAsync`
|
||||||
|
(`git merge --abort`).
|
||||||
|
|
||||||
|
New DTOs (defined in the worker hub DTO file, mirrored client-side):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||||
|
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||||
|
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing DTOs reused unchanged: `MergeResultDto(Status, ConflictFiles, ErrorMessage)`,
|
||||||
|
`MergePreviewDto`, `MergeTargetsDto`.
|
||||||
|
|
||||||
|
### 3. Conflict data model (UI)
|
||||||
|
|
||||||
|
`ConflictFile { Path, Hunks[] }`, `ConflictHunk { Ours, Theirs, Base, Resolution }`.
|
||||||
|
Shaped so a future 3-way merge pane needs no model change (Layer C is the inline
|
||||||
|
resolver now; the model leaves room for 3-way later).
|
||||||
|
|
||||||
|
### 4. Integration seams (delegates, wired by the integrator at merge)
|
||||||
|
|
||||||
|
A's and B's cockpits hold a `RequestConflictResolution(string taskId)` callback (an
|
||||||
|
`Action<string>` or `Func<string, Task>`). They never reference Layer C's resolver
|
||||||
|
types. The integrator connects these callbacks to C's `ConflictResolverViewModel`
|
||||||
|
factory when merging the three branches together.
|
||||||
|
|
||||||
|
## Parallel boundaries (verified disjoint)
|
||||||
|
|
||||||
|
| Area | A (this session) | B (parallel) | C (parallel) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `DiffView` + diff model/parser | builds | reuses | reuses |
|
||||||
|
| `WorkConsole.axaml` / `DetailsIslandViewModel` | owns | — | — |
|
||||||
|
| `DiffModalView` + `PlanningDiffView` | migrates to `DiffView` | — | — |
|
||||||
|
| `WorktreesOverviewModalView/VM` + `WorktreeModalView` | — | owns | — |
|
||||||
|
| `WorkerHub` / `TaskMergeService` / `GitService` | — | — | owns |
|
||||||
|
| New `ConflictResolverView/VM` + conflict UI model | — | — | owns |
|
||||||
|
| `IWorkerClient` / `WorkerClient` | adds frozen stubs + DTOs | reuses `MergeTaskAsync` | fills stub bodies |
|
||||||
|
| Test fakes (`IWorkerClient`) in both test projects | adds new no-op methods | — | makes them functional if needed |
|
||||||
|
|
||||||
|
The only file C and A both touch is `WorkerClient.cs` (C replaces the stub bodies A
|
||||||
|
wrote). Contained; reconciled at integration. Everything else is disjoint.
|
||||||
|
|
||||||
|
## Layer A — review/merge cockpit (this session)
|
||||||
|
|
||||||
|
- The Git tab becomes the single Approve + merge surface. `Approve` and the merge
|
||||||
|
target / preview / diff flow together as one block (no separate REVIEW vs
|
||||||
|
MERGE & WORKTREE sections).
|
||||||
|
- `Continue` (reject → requeue with feedback) and `Reset` (reject → idle) **stay** in
|
||||||
|
the Output tab footer — unchanged.
|
||||||
|
- The diff is shown via the unified `DiffView` opened as a modal from the cockpit. No
|
||||||
|
inline diff recap in the tab (the island is too small).
|
||||||
|
- On a single-task **Approve that conflicts**: instead of today's auto-abort, call
|
||||||
|
`StartConflictMergeAsync` and fire `RequestConflictResolution(taskId)`. This leaves
|
||||||
|
the main checkout mid-merge until the user resolves or aborts (behavior change,
|
||||||
|
intended). The callback is inert until Layer C is merged; the integrator wires it.
|
||||||
|
- Migrate `DiffModalView` and `PlanningDiffView` onto the new `DiffView`.
|
||||||
|
|
||||||
|
### Behavior change accepted
|
||||||
|
|
||||||
|
Today `MergeTask`/`ApproveReview` use `leaveConflictsInTree: false` (auto-abort on
|
||||||
|
conflict). Under this design, an Approve that conflicts leaves the merge in progress
|
||||||
|
and opens the resolver. The mid-merge guard (`IsMidMergeAsync`) still prevents a second
|
||||||
|
concurrent merge.
|
||||||
|
|
||||||
|
## Layer B — multi-worktree merge cockpit (parallel)
|
||||||
|
|
||||||
|
- Rework `WorktreesOverviewModalView`/`WorktreesOverviewModalViewModel` into a
|
||||||
|
batch-merge cockpit: list mergeable worktrees, select N, choose one target branch
|
||||||
|
(single target — 99% of the time everything goes to the same branch), "Merge all".
|
||||||
|
- **Skip-and-continue**: client-side loop calling the existing
|
||||||
|
`MergeTaskAsync(taskId, target, removeWorktree, msg)` per selected task. Clean merges
|
||||||
|
apply; conflicting ones are collected (existing `MergeTaskAsync` auto-aborts on
|
||||||
|
conflict, leaving the tree clean) into a "needs resolution" list with live progress.
|
||||||
|
- Each conflict row exposes a **Resolve** action → `RequestConflictResolution(taskId)`
|
||||||
|
(wired to Layer C at integration).
|
||||||
|
- Per-task diff via the shared `DiffView`; migrate `WorktreeModalView`'s inline diff
|
||||||
|
onto it.
|
||||||
|
- B touches no worker files — keeps it parallel-safe.
|
||||||
|
|
||||||
|
## Layer C — inline conflict resolver (parallel)
|
||||||
|
|
||||||
|
### Worker side
|
||||||
|
|
||||||
|
Implement the five frozen contract methods:
|
||||||
|
|
||||||
|
- Add hub methods `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`,
|
||||||
|
`ContinueMerge`, `AbortMerge` in `WorkerHub`.
|
||||||
|
- `StartConflictMerge` calls the existing `TaskMergeService.MergeAsync` overload with
|
||||||
|
`leaveConflictsInTree: true`.
|
||||||
|
- `ContinueMerge` / `AbortMerge` wrap the existing `TaskMergeService.ContinueMergeAsync`
|
||||||
|
/ `AbortMergeAsync` (currently service-level only, not hub-exposed).
|
||||||
|
- `GetMergeConflicts` reads ours/theirs/base per conflicted file via
|
||||||
|
`git show :2:/:3:/:1:`; add the `GitService` helpers needed.
|
||||||
|
- `WriteConflictResolution` writes the resolved content to `WorkingDir` and stages it.
|
||||||
|
- Fill the `WorkerClient` stub bodies (real SignalR `InvokeAsync` calls).
|
||||||
|
- Update the hand-rolled `IWorkerClient` fakes in both test projects.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- New `ConflictResolverView` + `ConflictResolverViewModel`. Per conflict hunk, show
|
||||||
|
ours vs theirs stacked, with buttons **Accept Current / Accept Incoming / Accept Both
|
||||||
|
/ Edit manually** plus a free-text box for the merged result of that hunk.
|
||||||
|
- When every file's hunks are resolved → `ContinueMergeAsync(taskId)` → `MergeResultDto`
|
||||||
|
(`merged` closes the resolver; `conflict` means not fully resolved, stay open).
|
||||||
|
- `AbortMergeAsync(taskId)` cancels and aborts the merge.
|
||||||
|
- Expose a factory (`Func<string, ConflictResolverViewModel>`) the integrator wires to
|
||||||
|
A's and B's `RequestConflictResolution` callbacks.
|
||||||
|
|
||||||
|
## Build / test
|
||||||
|
|
||||||
|
`.slnx` needs .NET 9; on .NET 8 build individual csproj with `-c Release` (a running
|
||||||
|
Worker locks `Debug`). Run the relevant test projects. No tests that spawn the real
|
||||||
|
`claude` CLI. Keep `en.json`/`de.json` localization keys in parity.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Full 3-way synchronized merge editor (model leaves room; not built now).
|
||||||
|
- Per-task differing merge targets in the batch (single target only).
|
||||||
|
- Any CI/PR tooling (direct push-to-main workflow).
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Terminal-style review controls
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Approved (design)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Review feedback today is a multi-line `TextBox` plus four buttons (Approve / Reject /
|
||||||
|
Park / Cancel) tucked into the WorkConsole **Session** tab
|
||||||
|
(`WorkConsole.axaml:169-193`). It feels disconnected from the live terminal. Entering
|
||||||
|
feedback should feel like typing into the terminal, with action buttons docked at the
|
||||||
|
bottom — and merge/approve actions should live in an obvious, dedicated place.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
- Type review feedback directly in the **Output (terminal)** tab, prompt-style.
|
||||||
|
- Bottom-docked action strip on the terminal: `[Retry]` `[Reset]`.
|
||||||
|
- Move all git/merge/worktree actions (including **Approve**) into a new **Git** tab so
|
||||||
|
it is obvious where each action lives.
|
||||||
|
|
||||||
|
## Tab structure
|
||||||
|
|
||||||
|
Three tabs in WorkConsole: **Output** · **Git** · **Session**.
|
||||||
|
|
||||||
|
| Tab | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| **Output** | Live `Log` (unchanged) + new review footer (below), footer gated on `IsWaitingForReview`. |
|
||||||
|
| **Git** | The current "Merge & worktree" block — merge-target dropdown, mergeability indicator, **Approve**, Open Diff, Merge, Worktree, Review Combined Diff, Merge All Subtasks. Visibility gated on `ShowMergeSection` / `IsWaitingForReview` as today. |
|
||||||
|
| **Session** | Child outcomes + empty-state only. |
|
||||||
|
|
||||||
|
### ViewModel changes (`DetailsIslandViewModel`)
|
||||||
|
|
||||||
|
- Add `public bool IsGitTab => SelectedTab == "git";`
|
||||||
|
- Add `[NotifyPropertyChangedFor(nameof(IsGitTab))]` alongside the existing
|
||||||
|
`IsOutputTab` / `IsSessionTab` notifications on `SelectedTab` (`:139-144`).
|
||||||
|
- `SelectTab` already accepts a string parameter — no change beyond the new `"git"`
|
||||||
|
value wired from XAML.
|
||||||
|
- No command renames (avoids breaking hand-rolled test fakes).
|
||||||
|
|
||||||
|
## Terminal footer (Output tab)
|
||||||
|
|
||||||
|
A `Border` docked `Bottom` inside the Output tab body, visible only when
|
||||||
|
`IsWaitingForReview`:
|
||||||
|
|
||||||
|
- Background `Surface2Brush`, top border `LineBrush` (`BorderThickness="0,1,0,0"`).
|
||||||
|
- A `❯` prompt-prefix `TextBlock` (mono, `TextMuteBrush`) + a borderless mono `TextBox`:
|
||||||
|
- Bound `Text="{Binding ReviewFeedback, Mode=TwoWay}"`.
|
||||||
|
- `AcceptsReturn="True"`, `TextWrapping="Wrap"`, transparent background, no border.
|
||||||
|
- Starts ~1 line tall; grows with content up to `MaxHeight≈160`, then scrolls.
|
||||||
|
- `PlaceholderText` e.g. "Feedback for the next run…".
|
||||||
|
- Right-aligned button strip:
|
||||||
|
- `[Retry]` — `Classes="btn accent"` → `RejectReviewCommand`.
|
||||||
|
- `[Reset]` — `Classes="btn"` → `ParkReviewCommand`.
|
||||||
|
|
||||||
|
`[Accept]` is **not** in the footer; approval happens on the Git tab via
|
||||||
|
`ApproveReviewCommand`. The old `Cancel` review button is dropped from this UI; cancel
|
||||||
|
remains reachable through the task's existing cancel control (`CancelReviewCommand`
|
||||||
|
stays on the ViewModel, just not surfaced here).
|
||||||
|
|
||||||
|
### Enter handling (`WorkConsole.axaml.cs`)
|
||||||
|
|
||||||
|
- Handle `KeyDown` on the input `TextBox`:
|
||||||
|
- **Enter** without Shift → execute `RejectReviewCommand` (if it can execute) and set
|
||||||
|
`e.Handled = true`.
|
||||||
|
- **Shift+Enter** → fall through to default behavior (inserts newline).
|
||||||
|
- `RejectReviewAsync` already returns early on whitespace-only feedback
|
||||||
|
(`DetailsIslandViewModel.cs:1464`), so pressing Enter with an empty prompt is a no-op
|
||||||
|
with no extra guard needed.
|
||||||
|
|
||||||
|
## Command mapping
|
||||||
|
|
||||||
|
| Button | Location | Command | Effect |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `[Retry]` | Output footer | `RejectReviewCommand` | Reject-to-queue with feedback; resumes the session (Queued). |
|
||||||
|
| `[Reset]` | Output footer | `ParkReviewCommand` | Park back to Idle. |
|
||||||
|
| `[Approve]` | Git tab | `ApproveReviewCommand` | Merge `SelectedMergeTarget` → Done (conflict keeps it in review). |
|
||||||
|
|
||||||
|
## Copy / empty state
|
||||||
|
|
||||||
|
- Update the Session empty-state text (`WorkConsole.axaml:270`) — it currently says
|
||||||
|
"review and merge controls appear here once the run finishes", which is no longer
|
||||||
|
accurate. Reword to reflect that only outcomes live on Session.
|
||||||
|
- Button labels remain literal strings (`Retry`, `Reset`, `Approve`), matching the
|
||||||
|
existing review buttons (no new localization keys).
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- No changes to worker-side review/merge logic or `IWorkerClient` signatures.
|
||||||
|
- No merge-target selector duplicated into the terminal footer (Approve uses the Git
|
||||||
|
tab dropdown / default target).
|
||||||
|
- No command renames on the ViewModel.
|
||||||
|
|
||||||
|
## Testing / verification
|
||||||
|
|
||||||
|
- Build `ClaudeDo.App` and `ClaudeDo.Worker` in `-c Release`.
|
||||||
|
- Manual visual verification (must be flagged — cannot be auto-verified):
|
||||||
|
- Footer appears only in `WaitingForReview`, on the Output tab.
|
||||||
|
- Enter sends Retry; Shift+Enter inserts a newline; empty Enter does nothing.
|
||||||
|
- Git tab shows Approve + merge/worktree controls; Session shows only outcomes.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Per-task model override via MCP + cheapest-model prompt guidance
|
||||||
|
|
||||||
|
Date: 2026-06-09
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let Claude pick the model for each task it generates (planning subtasks,
|
||||||
|
improvement follow-ups, external task creation) directly at creation time via
|
||||||
|
MCP, and instruct Claude — in the relevant prompts — to choose the *cheapest*
|
||||||
|
model that can do the job well.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
- `TaskEntity.Model` (nullable) already exists and is resolved
|
||||||
|
task → list-config → global default in `TaskRunner.ResolveConfigAsync`, then
|
||||||
|
passed to the CLI as `--model` by `ClaudeArgsBuilder`.
|
||||||
|
- Today the model can only be set *after* creation via `set_task_config`
|
||||||
|
(`ConfigMcpTools.SetTaskConfig`). The creation tools (`CreateChildTask`,
|
||||||
|
`SuggestImprovement`, `AddTask`) accept no model, so assigning one is a
|
||||||
|
two-call dance.
|
||||||
|
- `ModelRegistry.Aliases = ["sonnet","opus","haiku"]`; no cost ordering or
|
||||||
|
validation helper exists.
|
||||||
|
|
||||||
|
No schema change is required — only plumbing a `model` argument through the
|
||||||
|
creation paths plus prompt edits.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **Validation:** strict alias-only. `model` must be one of haiku/sonnet/opus
|
||||||
|
(case-insensitive); blank/null means "inherit" (no override); anything else
|
||||||
|
throws an MCP error so Claude self-corrects immediately rather than the task
|
||||||
|
failing later at CLI runtime.
|
||||||
|
- **`AddSubtask` is out of scope:** it creates a `SubtaskEntity` (a checklist
|
||||||
|
step), which is never independently executed — a model there is a no-op.
|
||||||
|
- **Improvement-child prompt:** the child's model is fixed at filing time and
|
||||||
|
it cannot re-pick, so only a one-line "this is an intentionally small/cheap
|
||||||
|
unit — stay minimal" reminder is added. The real model-choice instruction
|
||||||
|
lives in the main system prompt's SuggestImprovement guidance.
|
||||||
|
|
||||||
|
## Cost ordering & heuristic (single source: `ModelRegistry.ByCostAscending`)
|
||||||
|
|
||||||
|
`haiku < sonnet < opus`
|
||||||
|
|
||||||
|
- **haiku** — trivial/mechanical: doc tweaks, simple renames, small localized edits.
|
||||||
|
- **sonnet** — normal coding work (default).
|
||||||
|
- **opus** — complex architecture, cross-cutting changes, hard debugging.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
1. **`ClaudeDo.Data/Models/ModelRegistry.cs`**
|
||||||
|
- `ByCostAscending = ["haiku","sonnet","opus"]`.
|
||||||
|
- `string? NormalizeAlias(string? model)` — trim; null/blank → null;
|
||||||
|
case-insensitive match → canonical lowercase alias; else throw
|
||||||
|
`ArgumentException` with the allowed list.
|
||||||
|
|
||||||
|
2. **`TaskRepository.CreateChildAsync`** — add optional `string? model = null`;
|
||||||
|
set `child.Model = ModelRegistry.NormalizeAlias(model)`. Single choke-point
|
||||||
|
for both child-creation MCP tools.
|
||||||
|
|
||||||
|
3. **MCP creation tools** (add `model` param, document in `[Description]`):
|
||||||
|
- `PlanningMcpService.CreateChildTask` → forward to `CreateChildAsync`.
|
||||||
|
- `TaskRunMcpService.SuggestImprovement` → forward to `CreateChildAsync`.
|
||||||
|
- `ExternalMcpService.AddTask` → `NormalizeAlias` then set `entity.Model`.
|
||||||
|
|
||||||
|
4. **Prompts (`PromptFiles.cs`)**
|
||||||
|
- `PlanningSystemDefault` — instruct the planner to pass each
|
||||||
|
`CreateChildTask` the cheapest capable model (with the ordering/heuristic).
|
||||||
|
- `SystemDefault` (Out-of-scope improvements) — when filing via
|
||||||
|
`SuggestImprovement`, pass the cheapest capable `model`.
|
||||||
|
- `ImprovementChildDefault` — one-line minimality reminder.
|
||||||
|
|
||||||
|
5. **Tests** (no real CLI):
|
||||||
|
- `NormalizeAlias`: valid aliases (any case), blank/null → null, unknown → throws.
|
||||||
|
- `CreateChildTask` / `SuggestImprovement` / `AddTask` persist the model;
|
||||||
|
invalid model is rejected.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- No DB migration. No locale changes (prompts and MCP descriptions are not
|
||||||
|
localized). No UI changes (existing per-task model display already covers it).
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Unify the parent-task model (planning · improvement · normal)
|
||||||
|
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Status:** Approved-pending-implementation
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
ClaudeDo has three ways a task produces and waits on work, grown as separate
|
||||||
|
mechanisms that represent the *same shape* — "a task runs, may emit children,
|
||||||
|
and once it + its children are terminal it surfaces for review":
|
||||||
|
|
||||||
|
| | children authored | scheduling | parent flow today | merge of children |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Normal** | none | — | `Running → WaitingForReview → Done` | own worktree on approve |
|
||||||
|
| **Improvement** | autonomously *during* run (`suggest_improvement`) | parallel (no blockers) | `Running → WaitingForChildren → WaitingForReview → Done` | separate `MergeAllPlanning` |
|
||||||
|
| **Planning** | interactively *before* run (planning session) | sequential chain (`BlockedByTaskId`) | `Idle →(Active→Finalized)→ Done` (skips review) | separate `MergeAllPlanning` |
|
||||||
|
|
||||||
|
The incidental divergence we want to remove:
|
||||||
|
|
||||||
|
1. **Two "parent is waiting on children" representations** — improvement uses
|
||||||
|
`Status=WaitingForChildren`; planning uses `PlanningPhase=Finalized` with the
|
||||||
|
parent's `Status` jumping `Idle → Done`, never passing through the waiting/review
|
||||||
|
states at all.
|
||||||
|
2. **Two parent-advance methods** doing the same job —
|
||||||
|
`TaskRepository.TryCompleteParentAsync` (planning → `Done`, no review) vs
|
||||||
|
`TaskStateService.TryAdvanceImprovementParentAsync` (improvement → `WaitingForReview`).
|
||||||
|
3. **A separate merge action** — `MergeAllPlanning` / `PlanningMergeOrchestrator`
|
||||||
|
merges children, decoupled from the parent's `approve`. Approving a parent and
|
||||||
|
merging its unit are two clicks.
|
||||||
|
|
||||||
|
What is **genuinely unique and kept**: `PlanningPhase.Active` — the interactive,
|
||||||
|
human-in-the-loop authoring gate where children are drafted and cannot run until
|
||||||
|
finalize. Improvement has no equivalent. The two *authoring* entry points
|
||||||
|
(`PlanningMcpService.CreateChildTask` vs `TaskRunMcpService.SuggestImprovement`)
|
||||||
|
also stay distinct — they already share `CreateChildAsync`; unifying the authoring
|
||||||
|
UX is explicitly out of scope.
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
- **All parents get review.** A planning parent now surfaces in `WaitingForReview`
|
||||||
|
after its children finish, instead of auto-completing to `Done`.
|
||||||
|
- **Approve merges the whole unit — full UX consolidation.** Approve is the single
|
||||||
|
entry for reviewing *and* merging any task. For a parent with children it drives the
|
||||||
|
existing `PlanningMergeOrchestrator` (unit merge + parent→`Done` + conflict
|
||||||
|
continue/abort, all already implemented); the standalone "Merge All" button is
|
||||||
|
removed and the orchestrator's conflict dialog + combined-diff preview are reused
|
||||||
|
in-place. Childless tasks keep `ApproveAndMergeAsync`.
|
||||||
|
- **Scope = state model + code paths.** Internal refactor; authoring UX and child
|
||||||
|
base-commit resolution are unchanged.
|
||||||
|
|
||||||
|
## Target model
|
||||||
|
|
||||||
|
**One parent-with-children lifecycle, used by every parent regardless of how its
|
||||||
|
children were authored:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ (no children) ──────────────┐
|
||||||
|
Idle → Queued → Running ──┤ ├→ WaitingForReview → Done
|
||||||
|
└─ (has/spawns children) ─┐ │ (approve =
|
||||||
|
│ │ merge unit)
|
||||||
|
WaitingForChildren ─┘ │
|
||||||
|
│ │
|
||||||
|
(all children terminal) ───────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Planning parent (never runs as an agent — it runs an interactive session):
|
||||||
|
|
||||||
|
```
|
||||||
|
Idle (PlanningPhase None)
|
||||||
|
→[StartPlanning] Idle (PlanningPhase Active) ← authoring gate (KEPT)
|
||||||
|
→[FinalizePlanning] WaitingForChildren (Finalized) ← children chain runs
|
||||||
|
→[all children terminal] WaitingForReview
|
||||||
|
→[approve] merge unit → Done
|
||||||
|
```
|
||||||
|
|
||||||
|
Children (planning **and** improvement) keep going straight to `Done` with no
|
||||||
|
individual review; they accumulate on their branches and merge as a unit when the
|
||||||
|
parent is approved.
|
||||||
|
|
||||||
|
### State machine after the change
|
||||||
|
|
||||||
|
- `WaitingForChildren` is the **single** "parent waiting on children" state, used by
|
||||||
|
both planning and improvement parents.
|
||||||
|
- `WaitingForReview` is reached by every parent before `Done`.
|
||||||
|
- `PlanningPhase`: `None | Active | Finalized` — unchanged; `Active` remains the
|
||||||
|
authoring gate, `Finalized` marks "was a planning parent" and is set together with
|
||||||
|
`Status=WaitingForChildren`.
|
||||||
|
|
||||||
|
## Code changes
|
||||||
|
|
||||||
|
1. **Single parent-advance path.** Rename
|
||||||
|
`TaskStateService.TryAdvanceImprovementParentAsync` →
|
||||||
|
`TryAdvanceParentAsync`; it already only checks `Status==WaitingForChildren` +
|
||||||
|
"all children terminal" → `WaitingForReview` (with the failed/cancelled
|
||||||
|
annotation on `Result`). It becomes the only path for both systems.
|
||||||
|
- Handle **zero children**: a finalized planning parent with no children must go
|
||||||
|
straight to `WaitingForReview` (today `TryComplete`/`TryAdvance` both `return`
|
||||||
|
on `Count == 0`).
|
||||||
|
|
||||||
|
2. **Delete `TaskRepository.TryCompleteParentAsync`** (`TaskRepository.cs:477`) and
|
||||||
|
its invocation in `TaskStateService.OnChildTerminalAsync`. Planning parents now
|
||||||
|
advance via `TryAdvanceParentAsync` to `WaitingForReview` instead of `Done`.
|
||||||
|
- Keep `_chain.OnChildFinishedAsync` (inter-child unblock — planning-only effect).
|
||||||
|
|
||||||
|
3. **`FinalizePlanningAsync`** (`TaskStateService.cs:289`) sets the parent
|
||||||
|
`Status = WaitingForChildren` in the same update that sets
|
||||||
|
`PlanningPhase = Finalized`. This happens before `SetupChainAsync` enqueues
|
||||||
|
child[0], so the parent is in `WaitingForChildren` before any child can finish.
|
||||||
|
|
||||||
|
4. **Approve merges the unit.** `WorkerHub.ApproveReview` (and the MCP
|
||||||
|
`ReviewTask` approve path): when the approved task has children, run
|
||||||
|
`PlanningMergeOrchestrator` (parent worktree if `Active` + each `Done` child in
|
||||||
|
order), then transition the parent to `Done`. On a child merge conflict, the
|
||||||
|
parent stays in `WaitingForReview` (mirrors current single-task approve-conflict
|
||||||
|
behavior). Retire the `MergeAllPlanning` Hub method + UI button.
|
||||||
|
|
||||||
|
5. **Allow cancelling a `WaitingForChildren` parent.** Add `WaitingForChildren` to
|
||||||
|
the `CancelAsync` guard so a parent waiting on children can be cancelled (today it
|
||||||
|
cannot — minor gap).
|
||||||
|
|
||||||
|
6. **Docs.** Fix the `WaitingForChildren`-missing drift in
|
||||||
|
`src/ClaudeDo.Data/CLAUDE.md` and `src/ClaudeDo.Worker/CLAUDE.md`, and update the
|
||||||
|
transition diagram + the root `CLAUDE.md` status-flow line to the unified model.
|
||||||
|
|
||||||
|
## Out of scope (unchanged)
|
||||||
|
|
||||||
|
- Authoring UX: planning session vs `suggest_improvement` stay as two distinct
|
||||||
|
entry points (both already call `CreateChildAsync`).
|
||||||
|
- `WorktreeManager.ResolveBaseCommitAsync` base-commit divergence (planning children
|
||||||
|
branch from list HEAD; improvement children from parent head) — left as-is.
|
||||||
|
- Sequential-vs-parallel scheduling — already shared infrastructure
|
||||||
|
(`BlockedByTaskId`); planning chains, improvement doesn't. No change.
|
||||||
|
|
||||||
|
## Risks / edge cases
|
||||||
|
|
||||||
|
- **Ordering on finalize** — parent must be `WaitingForChildren` before the first
|
||||||
|
child can reach terminal. Guaranteed by setting it inside `FinalizePlanningAsync`,
|
||||||
|
which runs before `SetupChainAsync`.
|
||||||
|
- **Zero-children planning parent** — must advance to `WaitingForReview`, not stick
|
||||||
|
in `WaitingForChildren`. Explicit branch in `TryAdvanceParentAsync` /
|
||||||
|
`FinalizePlanningAsync`.
|
||||||
|
- **Failed/cancelled children** — parent still advances to `WaitingForReview` with
|
||||||
|
the existing `⚠ Children: N failed, M cancelled` annotation; no wedge.
|
||||||
|
- **Approve-merge conflict** — keep parent in `WaitingForReview`; surface the
|
||||||
|
conflicting child like the current merge-conflict path.
|
||||||
|
- **Existing rows** — planning parents currently sitting at `Idle`+`Finalized` with
|
||||||
|
live children: behavior change is forward-only (new finalizes use the new flow);
|
||||||
|
no migration needed since `Status`/`PlanningPhase` columns already exist.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Online Inbox — desktop-side design
|
||||||
|
|
||||||
|
Date: 2026-06-10
|
||||||
|
Status: approved, implementing
|
||||||
|
Related: `docs/online-inbox-api-contract.md` (the API both ends share)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let the owner add task ideas and view their Idle backlog from a phone/browser. The desktop
|
||||||
|
ClaudeDo opts in to an online service, syncs its list catalog + Idle backlog up, and pulls
|
||||||
|
web-created tasks down as local `Idle` tasks. Execution stays 100% local.
|
||||||
|
|
||||||
|
This spec covers only the **desktop side** (this repo). The API + web client are built
|
||||||
|
VPS-side against the shared contract.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No remote execution; the Worker still runs everything locally.
|
||||||
|
- No syncing of any task state other than the `Idle` mirror.
|
||||||
|
- No multi-user. Single Zitadel user = the owner.
|
||||||
|
- Web client is create + read only.
|
||||||
|
|
||||||
|
## Opt-in & where things live
|
||||||
|
|
||||||
|
- **Off by default.** When disabled: zero network, zero auth — byte-for-byte today's
|
||||||
|
behaviour. Auth only matters once enabled.
|
||||||
|
- Sync runs in the **Worker** (it owns the DB and already hosts `BackgroundService`s). The
|
||||||
|
opt-in config and the stored refresh token live in `worker.config.json`-adjacent state.
|
||||||
|
- Interactive Zitadel login happens in the **UI** (browser flow), which hands the resulting
|
||||||
|
refresh token to the Worker over SignalR; the Worker persists it (DPAPI) and uses it for
|
||||||
|
headless token refresh during polling.
|
||||||
|
|
||||||
|
## Config (`WorkerConfig`, new `online_inbox` section)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"online_inbox": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_base_url": "", // e.g. https://inbox.claudedo.kuns.dev
|
||||||
|
"poll_interval_seconds": 60,
|
||||||
|
"zitadel": {
|
||||||
|
"authority": "", // issuer URL (from VPS report)
|
||||||
|
"client_id": "",
|
||||||
|
"scopes": "openid offline_access" // offline_access → refresh token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The refresh token is NOT stored in this file. It lives encrypted via
|
||||||
|
`System.Security.Cryptography.ProtectedData` (DPAPI, CurrentUser) at
|
||||||
|
`~/.todo-app/online-inbox.token` and is read/written only by the Worker.
|
||||||
|
|
||||||
|
## Components (Worker, new `Online/` folder)
|
||||||
|
|
||||||
|
```
|
||||||
|
Worker/Online/
|
||||||
|
OnlineInboxConfig.cs — the config record (bound from WorkerConfig.OnlineInbox)
|
||||||
|
Dtos.cs — RemoteList, RemoteTask, MirrorTask DTOs (match the contract)
|
||||||
|
IOnlineInboxApi.cs — typed client surface (one method per endpoint)
|
||||||
|
OnlineInboxApiClient.cs — HttpClient impl; attaches bearer via IOnlineAuthProvider
|
||||||
|
Interfaces/IOnlineAuthProvider.cs — Task<string?> GetAccessTokenAsync(ct)
|
||||||
|
ZitadelAuthProvider.cs — concrete (PENDING: needs the Zitadel package + client config)
|
||||||
|
OnlineTokenStore.cs — DPAPI-backed refresh-token persistence
|
||||||
|
OnlineSyncService.cs — BackgroundService: the reconcile loop (§contract 5)
|
||||||
|
OnlineBacklog.cs — static helper: the Idle-backlog query/filter (§contract 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `IOnlineInboxApi`
|
||||||
|
```
|
||||||
|
Task PutListsAsync(IReadOnlyList<RemoteList> lists, ct)
|
||||||
|
Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(ct) // GET /tasks?imported=false
|
||||||
|
Task MarkImportedAsync(string id, ct) // POST /tasks/{id}/imported
|
||||||
|
Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, ct) // PUT /tasks/mirror
|
||||||
|
```
|
||||||
|
(The desktop never calls `POST /tasks`, `GET /lists`, or `GET /lists/{id}/tasks` — those are
|
||||||
|
web-only.)
|
||||||
|
|
||||||
|
### `IOnlineAuthProvider`
|
||||||
|
Single method `Task<string?> GetAccessTokenAsync(CancellationToken)` returning a bearer token
|
||||||
|
(refreshing transparently), or `null` if not logged in / refresh failed. Abstracting it lets
|
||||||
|
us:
|
||||||
|
- ship and test the sync engine now with a fake provider,
|
||||||
|
- wire the real `ZitadelAuthProvider` once the VPS reports authority/client-id and we add the
|
||||||
|
Zitadel package reference.
|
||||||
|
|
||||||
|
`ZitadelAuthProvider` reads the refresh token from `OnlineTokenStore`, exchanges it for an
|
||||||
|
access token, caches the access token until near expiry. **Marked with a
|
||||||
|
`// TODO(online-inbox)` until the flow is wired.**
|
||||||
|
|
||||||
|
> **Auth correction (2026-06-10):** the `KunsZitadel` nuget package is a *server-side*
|
||||||
|
> resource-server helper (`AddKunsZitadel` → `JwtBearer` token *validation*). It belongs on
|
||||||
|
> the VPS API, NOT the desktop. The desktop must *acquire* tokens, so `ZitadelAuthProvider`
|
||||||
|
> uses a client OIDC flow — `IdentityModel.OidcClient` (auth-code + PKCE, loopback redirect)
|
||||||
|
> or the device-authorization grant — against Zitadel's OIDC endpoints, then persists the
|
||||||
|
> refresh token via `OnlineTokenStore`.
|
||||||
|
|
||||||
|
### `OnlineSyncService` (the loop)
|
||||||
|
- Hosted only when `online_inbox.enabled == true` (guarded at registration).
|
||||||
|
- Every `poll_interval_seconds`: create a DI scope, resolve `TaskRepository` + `ListRepository`
|
||||||
|
(same pattern as the External MCP app), run the §5 reconcile loop.
|
||||||
|
- Skips a cycle (logs at debug) if `GetAccessTokenAsync` returns null (not logged in).
|
||||||
|
- All failures are caught per-cycle and logged; never crashes the Worker. Network errors back
|
||||||
|
off to the next interval.
|
||||||
|
- Import safety: a pulled task whose `listId` has no local list is skipped + logged (not
|
||||||
|
imported), and NOT marked imported, so it retries once the list exists. Imported tasks land
|
||||||
|
as `Status=Idle, CreatedBy="online"` — they never auto-run; the user queues them locally.
|
||||||
|
|
||||||
|
## UI (later increment, after VPS report)
|
||||||
|
|
||||||
|
- Settings modal → new "Online Inbox" section: enable toggle, API base URL, **Sign in /
|
||||||
|
Sign out** (Zitadel browser/device flow via the OIDC client lib), connection status.
|
||||||
|
- Login produces a refresh token; UI sends it to the Worker via a new hub method
|
||||||
|
`SetOnlineInboxAuth(refreshToken)` → Worker writes it through `OnlineTokenStore`.
|
||||||
|
- Config read/write via hub methods `GetOnlineInboxConfig` / `SetOnlineInboxConfig`
|
||||||
|
(mirrors the existing `GetAppSettings`/`UpdateAppSettings` pattern).
|
||||||
|
- Visual verification is a manual step (flagged — never claimed working without a run).
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Disabled → no network, no token read.
|
||||||
|
- Bearer attached only over HTTPS `api_base_url`; refuse `http://` non-loopback base URLs.
|
||||||
|
- Refresh token encrypted at rest (DPAPI CurrentUser). Never logged.
|
||||||
|
- Imported tasks are `Idle` only — no auto-execution path from the web.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `OnlineSyncService` reconcile logic tested against a **fake `IOnlineInboxApi`** + real
|
||||||
|
SQLite (Worker.Tests style): pull→import→flag, mirror set = Idle backlog, list catalog push,
|
||||||
|
unknown-list skip, disabled = no calls, not-logged-in = skipped cycle.
|
||||||
|
- `OnlineBacklog` filter tested directly (excludes children/planning/blocked/non-Idle).
|
||||||
|
- **No real network and no real Zitadel** in tests — fake the api + auth provider. (Consistent
|
||||||
|
with the no-real-Claude-in-tests rule.)
|
||||||
|
- DPAPI token store: round-trip test is Windows-only; guard or keep as a thin wrapper.
|
||||||
|
|
||||||
|
## Open items (need the VPS report)
|
||||||
|
|
||||||
|
- Exact Zitadel authority/issuer, client id, scopes, and **which grant the Zitadel app is
|
||||||
|
registered for** (auth-code+PKCE with which loopback redirect URI, or device-code). This
|
||||||
|
drives the desktop OIDC client implementation.
|
||||||
|
- Final API base URL.
|
||||||
|
- Desktop client OIDC library decision: `IdentityModel.OidcClient` (recommended) vs
|
||||||
|
hand-rolled device-code. (`KunsZitadel` is server-side only — see the auth correction
|
||||||
|
above; it's for the VPS API.)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Feature unification — one component per feature
|
||||||
|
|
||||||
|
Date: 2026-06-19
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
ClaudeDo grew organically; several features now exist as parallel implementations
|
||||||
|
or are reachable through many hand-wired entry points. This design maps the
|
||||||
|
duplication and defines a target where **each feature is one component**, reached
|
||||||
|
through one path, with dead code removed.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
Mapped via five parallel exploration agents (merge/conflict, review→merge,
|
||||||
|
diff+worktree, task create/edit, UI entry-point inventory), then verified the
|
||||||
|
load-bearing claims by grep/read before writing this. Every file:line below was
|
||||||
|
confirmed against the working tree on 2026-06-19.
|
||||||
|
|
||||||
|
## Key finding: it is NOT three merge engines
|
||||||
|
|
||||||
|
There is **one** merge engine (`TaskMergeService`), wrapped **once** for multi-child
|
||||||
|
units (`PlanningMergeOrchestrator`), with **one** conflict resolver (the Rider
|
||||||
|
3-pane). `Worker/CLAUDE.md` already records "there is no separate 'Merge all' entry —
|
||||||
|
approve is the single review+merge action." What *looks* like 2–3 merge features is
|
||||||
|
**entry-point sprawl** in the UI plus **one dead hunks-API** left over from the
|
||||||
|
Layer-C rework. So unification is mostly UI plumbing + deletion, not re-architecting
|
||||||
|
the engine.
|
||||||
|
|
||||||
|
## Findings — three buckets
|
||||||
|
|
||||||
|
### Bucket A — genuine duplication (parallel implementations of one job)
|
||||||
|
|
||||||
|
| # | Feature | Duplicated components | Shared already |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A1 | Diff viewing | `DiffModalViewModel` (worktree + commit-range), `WorktreeModalViewModel` (file-tree + per-file), `PlanningDiffViewModel` (per-subtask + integration) | `UnifiedDiffParser`, `DiffLinesView` (good) |
|
||||||
|
| A2 | Agent-config editing | `ListSettingsModalViewModel` (list scope), `AgentSettingsSectionViewModel` (task scope); global lives in `SettingsModalViewModel` | `InheritanceResolver`, `InheritedBadge` (good) |
|
||||||
|
| A3 | Worktree actions | `WorktreesOverviewModalViewModel` per-row cmds (Merge/Discard/Keep/ForceRemove/ShowDiff/Jump) vs `MergeSectionViewModel` (Merge/OpenDiff) | same `IWorkerClient` calls |
|
||||||
|
| A4 | Merge display | `AgentStripView` re-displays `MergeSectionViewModel` state | — |
|
||||||
|
|
||||||
|
### Bucket B — entry-point sprawl (one backend, many hand-wired doors)
|
||||||
|
|
||||||
|
| # | Feature | Doors | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B1 | Conflict-resolution seam | 5 copies of `Func<string,string,Task>? RequestConflictResolution` | `WorktreesOverviewModalViewModel.cs:83`, `DiffModalViewModel.cs:75`, `MergeModalViewModel.cs:33`, `MergeSectionViewModel.cs:51`, `DetailsIslandViewModel.cs:347` (delegates). Threaded through `MainWindow.axaml.cs:81`, `IslandsShellViewModel.cs:49/202`, `DiffModalViewModel.cs:103`, `MergeSectionViewModel.cs:159` |
|
||||||
|
| B2 | Diff (open) | 3–4 | MergeSection "Open Diff", TaskHeaderBar "Review Merged Diff", WorktreesOverview "Show Diff", Planning "Review Combined" |
|
||||||
|
| B3 | List Settings dialog | 3 | Lists context menu, Tasks header button, shell bridge `IslandsShellViewModel.cs:190-194` |
|
||||||
|
| B4 | Worktrees Overview | 2–3 | Repos menu (global), Lists context menu (per-list) |
|
||||||
|
| B5 | Repo Import | 2 | Repos menu, Lists footer button |
|
||||||
|
|
||||||
|
The conflict-resolution *target* is already single-point (`IslandsShellViewModel.RequestConflictResolutionAsync`, line 49). What is duplicated is the **seam plumbing**: five VMs each own the Func and it is threaded by hand.
|
||||||
|
|
||||||
|
### Bucket C — dead / leftover
|
||||||
|
|
||||||
|
| # | Item | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| C1 | Dead hunks conflict API | `TaskMergeService.GetConflictsAsync` (`Lifecycle/TaskMergeService.cs:250`) ← `WorkerHub.GetMergeConflicts` (`Hub/WorkerHub.cs:378`) ← `WorkerClient` `"GetMergeConflicts"` (`Services/WorkerClient.cs:276`) ← `IWorkerClient`. Live resolver uses `GetMergeConflictDocuments` (`WorkerHub.cs:389`). Only `TaskMergeServiceTests.cs:672` still references the old one. |
|
||||||
|
| C2 | Two task-creation paths | UI quick-add `TasksIslandViewModel.AddAsync` writes EF directly (`db.Tasks.Add`); MCP `ExternalMcpService.AddTask` is the service path. They can drift. |
|
||||||
|
| C3 | Stale worktrees | `.claude/worktrees/feat+planning-sessions-ui/…` carries old copies of `DiffModalViewModel`/`ListSettingsModalViewModel`/`WorktreeModalViewModel`; layer-c resolver leftovers. Worktree hygiene, not main code. |
|
||||||
|
| C4 | Naming drift (deferred) | Hub `StartConflictMerge`/`ContinueConflictMerge`/`AbortConflictMerge` (`WorkerHub.cs:367/405/414`) vs service `MergeAsync`/`ContinueMergeAsync`/`AbortMergeAsync`. **Documented as intentional** at `Worker/CLAUDE.md:153`. |
|
||||||
|
|
||||||
|
## Targets — one component per feature
|
||||||
|
|
||||||
|
1. **MergeCoordinator (B1).** Replace the five `RequestConflictResolution` Func seams with one injected coordinator exposing `MergeAsync(taskId, targetBranch)` that owns the "merge → on-conflict open resolver" sequence. Every door (review Approve, Diff Merge button, WorktreesOverview single + batch, Details merge section) calls it. The single resolution point (`IslandsShellViewModel.RequestConflictResolutionAsync`) becomes the coordinator's body.
|
||||||
|
2. **DiffViewer (A1 + B2).** One `DiffViewerViewModel` + view with a `DiffSource` abstraction (`DirtyWorktree | BranchVsBase | CommitRange | PlanningAggregate | IntegrationBranch`) and an optional file-tree pane. Replaces `DiffModal` + `WorktreeModal` + `PlanningDiff` shells; keeps `UnifiedDiffParser`/`DiffLinesView`. All B2 doors open it with a different source.
|
||||||
|
3. **WorktreeActions (A3).** One `WorktreeActionsViewModel` for a single task's worktree (merge/diff/discard/keep/force-remove), reused by both the overview rows and the Details merge section instead of each owning copies.
|
||||||
|
4. **AgentConfigEditor (A2).** One editor component parameterized by scope (`Global | List | Task`) over `InheritanceResolver`, embedded in Settings, List Settings, and the Details panel. Collapses the duplicated property set + reset commands + badges.
|
||||||
|
5. **DialogService (B3–B5).** Consolidate the per-modal `Show*` Func seams (`IslandsShellViewModel.cs:59-71`) into one `IDialogService` with typed open methods (`OpenListSettings(list)`, `OpenRepoImport()`, `OpenWorktreesOverview(listId?)`…). Menu, context menu, and footer all call the same method; duplicate command definitions across `ListsIsland`/shell collapse to one.
|
||||||
|
6. **Single task-creation path (C2).** Route UI quick-add through the same creation path MCP `AddTask` uses (repository/service), so both honor the same invariants.
|
||||||
|
|
||||||
|
Plus **C1** (delete dead hunks API + its test) and **C3** (prune stale worktrees) as groundwork. **C4** naming alignment is **deferred** — it is documented-intentional and would churn the hub + `WorkerClient` + every `IWorkerClient` fake (see the "fakes to sync" hazard) for cosmetic gain.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **Phased, each phase ships green.** Six independently buildable/committable slices; cheapest and lowest-risk first (see the plan). No big-bang.
|
||||||
|
- **One plan file per slice.** Matching the 2026-06-05 layer-A/B/C convention, each slice gets its own `docs/superpowers/plans/2026-06-19-unify-<slice>.md` authored when it is picked up. This umbrella plan sequences them and details Phase 0–1.
|
||||||
|
- **DiffViewer (A1) is last.** Highest effort and most UX-sensitive (file-tree vs whole-unified are different layouts); deferring it lets the cheaper wins land first and de-risks the big one.
|
||||||
|
- **Keep the merge engine and the resolver seam contract.** `TaskMergeService`, `PlanningMergeOrchestrator`, `ConflictResolverViewModel` ctor/`OpenAsync`/`OpenForPlanningAsync`/`CloseRequested` are unchanged — unification is above them.
|
||||||
|
- **Naming alignment deferred, not done** (rationale above).
|
||||||
|
|
||||||
|
## Out of scope / deferred
|
||||||
|
|
||||||
|
- Hub/service merge-method renaming (C4).
|
||||||
|
- Subtask deletion in the UI (a missing feature surfaced during mapping, not a duplicate).
|
||||||
|
- Any DB migration, worker engine change, or push.
|
||||||
|
|
||||||
|
## Acceptance (per phase)
|
||||||
|
|
||||||
|
Each phase: `dotnet build -c Release` clean for touched projects; the relevant test
|
||||||
|
project green; locales in parity (Localization.Tests) where keys change; the feature
|
||||||
|
reachable through its single new path with the old doors removed or delegating. UI
|
||||||
|
phases (2–5) flag a visual-verification gap for Mika to confirm in the running app.
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Rider-style 3-pane merge editor (conflict resolver redesign)
|
||||||
|
|
||||||
|
Date: 2026-06-19
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace ClaudeDo's current conflict resolver (3 read-only columns Base|Ours|Theirs,
|
||||||
|
one conflict at a time, accept buttons + editable result below) with a JetBrains
|
||||||
|
Rider-style **3-pane merge editor**:
|
||||||
|
|
||||||
|
- LEFT = **Ours** (read-only) · current branch / merge target
|
||||||
|
- MIDDLE = **Result** (editable) · the merged file being assembled
|
||||||
|
- RIGHT = **Theirs** (read-only) · incoming task branch
|
||||||
|
|
||||||
|
Whole file per pane (not one conflict at a time), color-coded conflict blocks,
|
||||||
|
inline per-hunk accept controls (`›` accept a side into the result, `✕` dismiss),
|
||||||
|
a `M conflicts · K resolved` readout, synced scrolling, Continue gated until every
|
||||||
|
conflict is resolved, Abort, and a binary-file guard. Visual reference: the
|
||||||
|
attached "Merge Revisions" screenshot.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
- Avalonia 12 desktop app; the conflict editor already uses **AvaloniaEdit 12.0.0**
|
||||||
|
+ `AvaloniaEdit.TextMate` (theme `StyleInclude` in `src/ClaudeDo.App/App.axaml`).
|
||||||
|
- **Backend is kept unchanged.** `WorkerHub.GetMergeConflictDocuments(taskId)` returns
|
||||||
|
each conflicted file as ordered `MergeSegment`s: *stable* text (git's already
|
||||||
|
auto-merged content) interleaved with *conflict* blocks carrying `Ours/Base/Theirs`.
|
||||||
|
`StartConflictMerge` / `WriteConflictResolution` / `Continue[Planning]ConflictMerge` /
|
||||||
|
`Abort[Planning]ConflictMerge` and their `IWorkerClient` mirrors stay as-is.
|
||||||
|
`ConflictMarkerParser` (Data) already produces the segments. **ours = merge target
|
||||||
|
(current branch); theirs = incoming task branch.** Merges are LOCAL-only (no push).
|
||||||
|
- **Seam kept unchanged** so single-task AND planning conflict paths keep working:
|
||||||
|
`IslandsShellViewModel.ConflictResolverFactory` + `ShowConflictResolver`
|
||||||
|
(wired in `MainWindow.axaml.cs`), VM ctor `(IWorkerClient, taskId)`,
|
||||||
|
`OpenAsync(targetBranch)`, `OpenForPlanningAsync(parentId, subtaskId)`, `CloseRequested`.
|
||||||
|
The planning-path WIP currently uncommitted in the tree (`OpenForPlanningAsync`,
|
||||||
|
`_conflictTaskId`, `LoadDocumentsAsync`) is part of this seam and is preserved.
|
||||||
|
|
||||||
|
### Key insight: the segments already line the panes up
|
||||||
|
|
||||||
|
Because every conflicted file is split into *stable* (identical on both sides, git
|
||||||
|
auto-merged) and *conflict* (divergent) segments, reconstructing three documents —
|
||||||
|
|
||||||
|
- **Ours** = Σ over segments of (stable.Text | conflict.Ours)
|
||||||
|
- **Theirs** = Σ over segments of (stable.Text | conflict.Theirs)
|
||||||
|
- **Result** = Σ over segments of (stable.Text | conflict.Resolution ?? conflict.Ours)
|
||||||
|
|
||||||
|
— yields three documents that are byte-identical in their stable regions and differ
|
||||||
|
only inside conflict blocks. So the panes align line-for-line for free, and a real
|
||||||
|
client-side 3-way diff is **not** needed for the core feature.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **Data source = segment-based (no backend change, no DiffPlex).** The worker already
|
||||||
|
applied git's auto-merge; only conflicts remain actionable. The screenshot's
|
||||||
|
"N changes" (non-conflicting hunks shown as separately flippable) are already merged
|
||||||
|
and have nothing to accept, so the readout is **`M conflicts · K resolved`**. True
|
||||||
|
"N changes" parity (raw `:1/:2/:3` blobs + DiffPlex 3-way) is an explicit later
|
||||||
|
add-on that does not touch the seam — see *Out of scope / fast-follow*.
|
||||||
|
- **One file at a time + file switcher.** Like Rider's title bar ("Merge Revisions for
|
||||||
|
…file"). When more than one file conflicts, a compact switcher selects the active
|
||||||
|
file; Continue still requires *all* files resolved. (Replaces today's cross-file
|
||||||
|
flattened one-at-a-time navigation as the primary model.)
|
||||||
|
- **Result-pane editing model.** The middle document is the merged file. Stable text is
|
||||||
|
read-only via `IReadOnlySectionProvider`; only conflict regions are editable. Each
|
||||||
|
conflict's result span is tracked in a `TextSegmentCollection` (anchors auto-adjust on
|
||||||
|
edit). Accepting `›`(ours)/`‹`(theirs) replaces that span; editing inside it or
|
||||||
|
accepting flips the block to **resolved**. Unresolved regions are seeded with the Ours
|
||||||
|
text and painted red until acted on.
|
||||||
|
- **Accept controls = overlay between panes** (not an AvaloniaEdit margin). A thin Canvas
|
||||||
|
overlay between Ours|Result and Result|Theirs hosts `›`/`✕` (and `‹`) per conflict,
|
||||||
|
positioned at each block's visual Y (recomputed on scroll/resize). This matches the
|
||||||
|
screenshot's between-pane gutters and avoids the lack of a built-in right-side margin.
|
||||||
|
- **Synced scroll = proportional (Green).** Mirror each pane's vertical scroll offset to
|
||||||
|
the other two with a re-entrancy guard. Aligned/virtual-space scroll + bezier connector
|
||||||
|
curves are a deferred stretch.
|
||||||
|
- **Seam + existing VM tests preserved.** Keep `MergeConflictBlock` with its
|
||||||
|
`AcceptOurs/Theirs/Both/Base` commands and `MergeFile.Compose`; keep
|
||||||
|
`Current`/`CurrentIndex`/`Next`/`Previous` repurposed as the focused-conflict the top
|
||||||
|
arrows jump to. New state (active file, readout) is additive.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### ViewModel (`ConflictResolverViewModel`, `ConflictModels.cs`)
|
||||||
|
|
||||||
|
Unchanged seam: ctor, `OpenAsync`, `OpenForPlanningAsync`, `CloseRequested`,
|
||||||
|
`Continue`/`Abort` (incl. planning routing), `CanContinue` gating, binary guard.
|
||||||
|
|
||||||
|
Additive:
|
||||||
|
- `ActiveFile` (`MergeFile`) + the switcher list (`Files`) + `SelectFileCommand`.
|
||||||
|
- Per-active-file reconstruction exposed for the view and for tests:
|
||||||
|
`ActiveOursText`, `ActiveTheirsText`, `ActiveResultText` (result seeds unresolved =
|
||||||
|
Ours), plus an ordered list of conflict descriptors (the block + its segment index)
|
||||||
|
so the view can compute offsets/spans as it assembles each document.
|
||||||
|
- Readout `PositionText` → `"{M} conflicts · {K} resolved"` (active file and/or total);
|
||||||
|
`CanContinue` stays "all files resolved AND no binary".
|
||||||
|
- On switching files, block `Resolution` persists (state lives on `MergeConflictBlock`),
|
||||||
|
so progress survives navigation; the view rebuilds documents from the active file.
|
||||||
|
|
||||||
|
### View (`Views/Conflicts/ConflictResolverView.axaml` + `.cs`)
|
||||||
|
|
||||||
|
- AXAML: ModalShell host (kept), header (prev/next arrows, file switcher, readout),
|
||||||
|
`Grid` of three bordered panes with headers, two between-pane overlay Canvases,
|
||||||
|
footer (Continue/Abort), binary banner, `Escape`→Abort. Drop the Base column.
|
||||||
|
- Code-behind builds three `TextDocument`s from `ActiveFile`'s segments, recording each
|
||||||
|
conflict's line span per document; installs TextMate by file extension on all three;
|
||||||
|
rebuilds on file switch; pushes result-pane edits back into the active block's
|
||||||
|
`Resolution` and flips resolved.
|
||||||
|
- `IReadOnlySectionProvider` on the Result `TextArea` (stable = read-only, conflicts =
|
||||||
|
editable) backed by a `TextSegmentCollection` of the conflict result-spans.
|
||||||
|
- One `IBackgroundRenderer` per pane painting unresolved-conflict (red), resolved
|
||||||
|
(green/muted), and ours/theirs side tints, driven by the recorded spans + block state.
|
||||||
|
- Overlay accept controls positioned at each block's `TextView` visual top; click →
|
||||||
|
`block.AcceptOurs/AcceptTheirs` and the code-behind replaces the tracked result span.
|
||||||
|
- Proportional synced vertical scroll across the three panes.
|
||||||
|
|
||||||
|
### Localization / tokens
|
||||||
|
|
||||||
|
- New `conflictResolver.*` keys (pane headers, readout, accept tooltips) in
|
||||||
|
`en.json` + `de.json` (parity enforced by Localization.Tests).
|
||||||
|
- Block colors from `Tokens.axaml` (reuse Blood/Moss/Accent tints; add tokens only if a
|
||||||
|
needed shade is missing).
|
||||||
|
|
||||||
|
## Out of scope / fast-follow (not in this plan)
|
||||||
|
|
||||||
|
- **Raw 3-way diff "N changes" parity (Option B):** a new worker method returning raw
|
||||||
|
`:1/:2/:3` blobs per conflicted file + DiffPlex client-side 3-way diff so
|
||||||
|
non-conflicting changes also appear as accept-able hunks. Seam-preserving; later.
|
||||||
|
- **Intra-conflict word/line highlighting** (Rider's "Highlight words") via a line
|
||||||
|
transformer.
|
||||||
|
- **Bezier connector curves + aligned / virtual-space synced scroll** (Red stretch).
|
||||||
|
- No DB migration, no backend/seam changes, no push.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Worker log → footer auto-route + Log Visualizer overlay
|
||||||
|
|
||||||
|
**Date:** 2026-06-23
|
||||||
|
**Status:** approved (design forks resolved with user)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
1. Auto-route **all Worker WARN/ERROR** Serilog events to the footer status strip (today only ~10 hand-curated business events reach it).
|
||||||
|
2. Make the footer log line **clickable** → opens a **Log Visualizer overlay** showing the **last 30 min** of logs at **all levels**, color-coded.
|
||||||
|
3. **Dedupe/rate-limit** the footer so repeating warnings (e.g. the current 60s OIDC-discovery failure) don't strobe.
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
- **Overlay source:** Worker-side **in-memory ring buffer** (30-min window, all levels), fetched via a hub call. No log-file parsing.
|
||||||
|
- **Levels:** overlay shows INF/WRN/ERR; footer flashes **WARN/ERROR only**.
|
||||||
|
- **Footer noise:** per-message dedupe within a rate-limit window (suppress the footer broadcast for an identical message seen recently; the event is still buffered for the overlay).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Worker
|
||||||
|
|
||||||
|
- **`LogRingBuffer`** (singleton, `Logging/`): thread-safe, time-bounded (`TimeSpan` window, default 30 min) + hard cap (e.g. 5000) ring of `WorkerLogRecord(Message, Level, TimestampUtc)`. Evicts on append by age + cap. `Snapshot()` returns newest-last.
|
||||||
|
- **`BroadcastLogSink : Serilog.Core.ILogEventSink`** (`Logging/`): for every `LogEvent` —
|
||||||
|
- map level: Verbose/Debug/Information→`Info`, Warning→`Warn`, Error/Fatal→`Error`;
|
||||||
|
- render `msg = evt.RenderMessage()` (+ `": {ex.GetType().Name}: {ex.Message}"` first-line if `evt.Exception != null`);
|
||||||
|
- append to `LogRingBuffer` (all levels);
|
||||||
|
- if `Warn|Error` **and** not rate-limited: fire-and-forget `HubBroadcaster.WorkerLog(msg, level, evt.Timestamp.UtcDateTime)`.
|
||||||
|
- **Loop guard:** wrap the broadcast in try/catch and swallow; skip broadcasting events whose `SourceContext` is SignalR/connections plumbing (still buffered). Broadcasting must never itself log.
|
||||||
|
- **Dedupe/rate-limit:** dict `message → lastBroadcastUtc`; suppress footer broadcast if `now - last < RateLimitWindow` (const, 120 s). Periodic prune of the dict.
|
||||||
|
- **DI wiring (chicken-egg):** `LogRingBuffer` + `BroadcastLogSink` are created as locals in `Program.cs` *before* `builder.Build()`, captured into `UseSerilog(... .WriteTo.Sink(broadcastSink))`, and registered as singletons. `HubBroadcaster` doesn't exist until post-build, so the sink starts detached; after `builder.Build()` we call `broadcastSink.Attach(app.Services.GetRequiredService<HubBroadcaster>())`. Buffering works from process start; broadcasting begins once attached.
|
||||||
|
- **Hub:** `WorkerHub.GetRecentLogs() -> IReadOnlyList<WorkerLogRecordDto>` reads `LogRingBuffer.Snapshot()`. (Read-only, no auth beyond existing hub.)
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- **IWorkerClient / WorkerClient:** add `Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync(CancellationToken ct = default)`. ⚠ Update hand-rolled fakes in **both** test projects (StubWorkerClient + Worker.Tests UiVm fake).
|
||||||
|
- **Footer:** wrap the worker-log `TextBlock` so it's clickable (Button, transparent) → `IslandsShellViewModel.OpenLogVisualizerCommand`. Existing `OnWorkerLogReceived` already routes the (now more numerous) `WorkerLog` broadcasts to the strip — **no change needed** for footer routing itself.
|
||||||
|
- **`LogVisualizerViewModel`** (Modals/): on open, `GetRecentLogsAsync()` → `ObservableCollection<LogLineViewModel>` (msg, level→brush, HH:mm:ss). A level filter (All / Warn+Err) and a Refresh command. MVP = snapshot on open + Refresh; live-tail is a later nicety.
|
||||||
|
- **`LogVisualizerView`** (Modals/): `ModalShell`-based dialog (consistent with other modals), shown via `IDialogService.ShowLogVisualizerAsync(vm)`. Small, scrollable, monospaced, color-coded lines.
|
||||||
|
- **Localization:** new `vm.logVisualizer` (+ any view keys) in **en.json + de.json** (parity test enforces).
|
||||||
|
|
||||||
|
## Out of scope / follow-ups
|
||||||
|
|
||||||
|
- Live-tail while the overlay is open (snapshot + Refresh for MVP).
|
||||||
|
- The **OIDC-discovery-every-60s failure** is a *separate* bug (Online Inbox enabled, `auth.kuns.dev` SSL fails). Dedupe tames the footer symptom; the root cause is tracked separately.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Worker: `LogRingBufferTests` (age + cap eviction, snapshot order), `BroadcastLogSinkTests` (level mapping; all levels buffered; only Warn/Err broadcast; dedupe suppresses repeat broadcast within window but still buffers; exception rendering; loop-guard source filter).
|
||||||
|
- UI: `LogVisualizerViewModelTests` (loads from worker, populates, filter). Footer-click wiring smoke.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Interactive "Answer Claude's Questions" — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-25
|
||||||
|
**Status:** Approved (brainstormed with Mika)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let the user answer a question Claude raises *mid-run* from inside Mission Control,
|
||||||
|
without leaving the autonomous-execution model. Not a chat panel, not a terminal, not
|
||||||
|
proactive steering — only: *Claude surfaces a question → the user types an answer → the
|
||||||
|
run continues with that answer in context.*
|
||||||
|
|
||||||
|
User decisions (brainstorm):
|
||||||
|
- Scope: "I mostly want to answer his questions if he surfaces any."
|
||||||
|
- Trigger: **any running task** may ask, with a **3-minute** answer window.
|
||||||
|
|
||||||
|
## Why not the alternatives
|
||||||
|
|
||||||
|
- **Embedded terminal / PTY** — would destroy the NDJSON contract the whole worker
|
||||||
|
pipeline depends on (StreamAnalyzer, token accounting, auto-commit, status flow) and
|
||||||
|
needs a terminal-emulator control Avalonia doesn't have. Rejected.
|
||||||
|
- **Streaming-stdin (`--input-format stream-json`)** — right tool for a free-form chat,
|
||||||
|
overkill here. Rejected for v1.
|
||||||
|
- **`--resume` per-turn** — already exists; not live (cold process per turn).
|
||||||
|
|
||||||
|
## Mechanism
|
||||||
|
|
||||||
|
The in-task MCP already blocks the `claude -p` process while a tool call is in flight.
|
||||||
|
That blocking *is* the pause. Add one in-task MCP tool, `AskUser(question)`:
|
||||||
|
|
||||||
|
1. The tool resolves the caller task id, registers a pending question + a
|
||||||
|
`TaskCompletionSource<string>` in a singleton `PendingQuestionRegistry`, and
|
||||||
|
broadcasts `TaskQuestionAsked(taskId, questionId, question)`.
|
||||||
|
2. Mission Control surfaces the question with an input box.
|
||||||
|
3. The user answers → `WorkerHub.AnswerTaskQuestion` resolves the TCS → the tool
|
||||||
|
returns the answer as its result → Claude continues.
|
||||||
|
4. No answer within **3 minutes** → the tool returns *"No response received within 3
|
||||||
|
minutes — proceed using your best judgment."* and the run carries on autonomously.
|
||||||
|
|
||||||
|
### Key facts that make this work
|
||||||
|
|
||||||
|
- **No persisted status change.** The task is still genuinely `Running` (process alive,
|
||||||
|
blocked mid-tool-call). "Waiting for input" is **ephemeral**: in-memory registry +
|
||||||
|
live SignalR events + a UI overlay. No `TaskStatus` enum value, no `TaskStateService`
|
||||||
|
transition, **no EF migration**. If the worker dies mid-wait, `StaleTaskRecovery`
|
||||||
|
flips the orphaned `Running` row to `Failed` like any interrupted run.
|
||||||
|
- **`MCP_TOOL_TIMEOUT` must be raised.** Claude Code caps HTTP MCP tool calls at **60 s**
|
||||||
|
by default. The `claudedo_run` MCP is HTTP, so `ClaudeProcess` must set
|
||||||
|
`MCP_TOOL_TIMEOUT=200000` (≈3 min + margin) on the spawned process or the 3-min window
|
||||||
|
is silently truncated to 60 s.
|
||||||
|
- **MCP wired for all runs.** Today `TaskRunner` only mints the run MCP for standalone
|
||||||
|
top-level tasks (for `SuggestImprovement`). To satisfy "any running task," move the
|
||||||
|
MCP-identity setup out of that gate so every `RunAsync` gets `claudedo_run`.
|
||||||
|
`AllowedTools` always includes `mcp__claudedo_run__AskUser`; `SuggestImprovement` stays
|
||||||
|
gated to improvement-eligible (standalone) runs.
|
||||||
|
|
||||||
|
## Surface changes
|
||||||
|
|
||||||
|
**Worker (mostly new files):**
|
||||||
|
- `Runner/PendingQuestionRegistry.cs` (new, singleton) — `Register`, `TryAnswer`, `Get`,
|
||||||
|
`Remove`; one pending question per task.
|
||||||
|
- `Runner/TaskRunMcpService.cs` (edit) — add `AskUser` `[McpServerTool]`; inject the
|
||||||
|
registry.
|
||||||
|
- `Runner/TaskRunner.cs` (edit) — wire MCP identity for all runs; add `AskUser` to
|
||||||
|
allowed tools.
|
||||||
|
- `Runner/ClaudeProcess.cs` (edit) — set `MCP_TOOL_TIMEOUT` env.
|
||||||
|
- `Hub/HubBroadcaster.cs` (edit) — `TaskQuestionAsked`, `TaskQuestionResolved`.
|
||||||
|
- `Hub/WorkerHub.cs` (edit) — `AnswerTaskQuestion`, `GetPendingQuestion` + DTO.
|
||||||
|
- `Program.cs` (edit) — register `PendingQuestionRegistry` singleton.
|
||||||
|
- System prompt (edit) — one line telling Claude the tool exists and to use it only when
|
||||||
|
a wrong guess would be costly/irreversible (otherwise proceed).
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- `Services/IWorkerClient.cs` + `WorkerClient.cs` (edit) — `AnswerTaskQuestionAsync`,
|
||||||
|
`GetPendingQuestionAsync`, `TaskQuestionAskedEvent`, `TaskQuestionResolvedEvent`.
|
||||||
|
- `ViewModels/Islands/TaskMonitorViewModel.cs` (edit, **hot file**) — pending-question
|
||||||
|
state, `AnswerDraft`, `SubmitAnswerCommand`, clear on finish/resolve.
|
||||||
|
- `ViewModels/MissionControlViewModel.cs` (edit) — hydrate pending question on attach.
|
||||||
|
- `Views/MissionControl/MonitorPaneView.axaml` (edit, **hot file**) — additive
|
||||||
|
question/answer banner above the terminal.
|
||||||
|
- `Localization/locales/en.json` + `de.json` — `missionControl.question.*` keys.
|
||||||
|
|
||||||
|
**Tests:** `PendingQuestionRegistry` (answer/timeout/unknown/overwrite), `AskUser` tool
|
||||||
|
(answer + timeout fallback, fake broadcaster — no real Claude), `TaskMonitorViewModel`
|
||||||
|
(surface/submit/clear). Update IWorkerClient fakes in both test projects.
|
||||||
|
|
||||||
|
## Concurrency note
|
||||||
|
|
||||||
|
Two files (`TaskMonitorViewModel.cs`, `MonitorPaneView.axaml`) are also being touched by
|
||||||
|
a concurrent Mission Control drag-and-drop session on the shared main tree. Keep edits
|
||||||
|
additive, commit explicit paths only (never `git add -A`).
|
||||||
|
|
||||||
|
## Verification gaps (manual)
|
||||||
|
|
||||||
|
1. **Real-Claude smoke test** — confirm a blocking `AskUser` call survives ≥3 min with
|
||||||
|
`MCP_TOOL_TIMEOUT=200000` and that the model actually calls the tool when uncertain.
|
||||||
|
2. **Visual** — the question banner + input box in the pane (Mika does the visual pass).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Free-form chat panel; proactive steering; tool-permission prompts (stays `auto`);
|
||||||
|
`ContinueAsync`/resumed runs gaining `AskUser` (deferred follow-up).
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Mission Control — multi-task live monitoring
|
||||||
|
|
||||||
|
Date: 2026-06-25
|
||||||
|
Status: approved (design); implementation not started
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The UI can observe only **one** running task at a time. `DetailsIslandViewModel` is hard 1:1
|
||||||
|
(single `Task`, single `_subscribedTaskId`); selecting another task in the middle pane *replaces*
|
||||||
|
what Details shows. Yet the worker runs several tasks concurrently (`MaxParallelExecutions`) and
|
||||||
|
already broadcasts every task's live output to all clients keyed by `taskId`. So the user cannot
|
||||||
|
watch multiple in-flight sessions, and monitoring blocks normal work (adding tasks, reviewing).
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Watch several running tasks at once **without** giving up the normal app. Requirements drawn from
|
||||||
|
the brainstorm:
|
||||||
|
|
||||||
|
- A **live console grid** — multiple full Claude output streams side by side.
|
||||||
|
- Each pane also shows **task details, blocking reasons**, and a **navigation helper** to open the
|
||||||
|
monitored task in the main app.
|
||||||
|
- Lives in a **separate, always-available window** so the main window stays fully usable (adding
|
||||||
|
tasks must never be blocked). Combines "full window" + "detachable".
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No worker/SignalR changes. The broadcast layer is already N-capable (`TaskMessage(taskId,line)`,
|
||||||
|
`TaskStarted/Finished/Updated`, `GetActive()`). This is a UI/VM-only feature.
|
||||||
|
- No second SignalR connection. The new window shares the existing singleton `IWorkerClient`.
|
||||||
|
- No new merge/review engine. Review/merge stays in the main window's Details pane; Mission Control
|
||||||
|
is read-mostly (monitor + cancel + navigate).
|
||||||
|
|
||||||
|
## Hard constraint: no duplicated components or features
|
||||||
|
|
||||||
|
This feature is an **extract-and-reuse** exercise, not a rebuild. The single biggest risk is
|
||||||
|
forking a second live-streaming/parsing/status implementation. The reuse map below is binding.
|
||||||
|
|
||||||
|
### Reuse map (what already exists — use it, do not copy it)
|
||||||
|
|
||||||
|
| Concern | Existing asset | Location | How Mission Control uses it |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Live console body (log list, LIVE/DONE/FAILED chip, auto-scroll) | `SessionTerminalView` (StyledProps `Entries`, `Label`, `IsRunning/IsDone/IsFailed`) | `Views/Islands/SessionTerminalView.axaml(.cs)` | Bind a pane's `Entries`→its `Log`, status flags + label. **No new console control.** |
|
||||||
|
| Log line model | `LogLineViewModel` + `LogKind` | `ViewModels/Islands/DetailsIslandViewModel.cs` (top) | Shared model — move to its own file so both consumers reference one type. |
|
||||||
|
| Live stream parse/replay | `OnTaskMessage` / `AppendStdoutLine` / `FlushClaudeBuffer` / `ReplayLogFileAsync` + `StreamLineFormatter` + `ExpandUserPath` | private in `DetailsIslandViewModel.cs` | **Extract to `TaskMonitorViewModel`** (Phase 1). One streaming engine, two consumers. |
|
||||||
|
| Status state machine | `AgentState` + `Is*` flags + `StatusToStateKey` / `FinishedStatusToStateKey` | `DetailsIslandViewModel.cs` | Extract into `TaskMonitorViewModel`. |
|
||||||
|
| Outcome / roadblock split | `ApplyOutcome` + `RoadblockMarker` constant | `DetailsIslandViewModel.cs` | Extract into `TaskMonitorViewModel`. |
|
||||||
|
| Status chip / terminal styling | `live-chip`, `terminal`, `log-*` style classes | `Design/IslandStyles.axaml` | Reuse the classes as-is. |
|
||||||
|
| Add a new task | `TasksIslandViewModel.AddAsync` (`NewTaskTitle`, user-list only, direct `TaskRepository`) | `TasksIslandViewModel.cs:406` | Optional quick-add reuses this path; **must not** introduce a second insert path. |
|
||||||
|
| Live task list | `IWorkerClient.GetActive()` + `TaskStarted/Finished` events | worker hub / `WorkerClient` | Populate the grid; add/remove panes. |
|
||||||
|
| DI / singletons | `IslandsShellViewModel`, `DetailsIslandViewModel`, `IWorkerClient` all singletons | `App/Program.cs` | Register `MissionControlViewModel` singleton; inject existing singletons. |
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### TaskMonitorViewModel (the reusable core — new, but carved out of DetailsIslandViewModel)
|
||||||
|
|
||||||
|
One instance == one monitored task. Owns:
|
||||||
|
|
||||||
|
- `Log` (`ObservableCollection<LogLineViewModel>`), the filtered `TaskMessageEvent` subscription
|
||||||
|
(by `taskId`), stdout buffering, and NDJSON replay from disk on attach.
|
||||||
|
- `AgentState` + `Is*` flags; `SessionOutcome` / `Roadblocks` (the outcome split).
|
||||||
|
- Lightweight display: `Title`, `TaskIdBadge`, `Model`, `TurnsText`, `TokensFormatted`,
|
||||||
|
diff add/del, elapsed.
|
||||||
|
- `BlockingReason` (string/visible flag) derived from existing data: `BlockedByTaskId`
|
||||||
|
(planning/child chain), `WaitingForReview` / `WaitingForChildren` status, and roadblock markers.
|
||||||
|
- Commands: `OpenInApp`, `Detach`, `Cancel`.
|
||||||
|
- `IDisposable` — unsubscribes all worker events (mirror DetailsIslandViewModel.Dispose).
|
||||||
|
|
||||||
|
`DetailsIslandViewModel` is refactored to **own one `TaskMonitorViewModel` (`public Monitor`)** and
|
||||||
|
delegate streaming/status/outcome to it. Its heavy concerns (subtasks, attachments, editing, merge
|
||||||
|
cockpit, review verbs, child outcomes, notes/prep modes) stay put. **Phase 1 must be a no-behavior-
|
||||||
|
change refactor** — all existing Ui.Tests stay green.
|
||||||
|
|
||||||
|
> Binding-surface decision (Phase 1): repoint `WorkConsole.axaml`'s Output-tab bindings that
|
||||||
|
> reference streaming/status (`Log`, `IsRunning/IsDone/IsFailed`, `SessionOutcome`, `TurnsText`,
|
||||||
|
> diff text, `Model`) to `Monitor.*`. `x:DataType` stays `DetailsIslandViewModel`; compiled bindings
|
||||||
|
> handle the nested path. Review/merge/session bindings are untouched. Prefer repointing over adding
|
||||||
|
> ~15 forwarding properties (one source of truth, no boilerplate).
|
||||||
|
|
||||||
|
### MissionControlViewModel (new)
|
||||||
|
|
||||||
|
- `ObservableCollection<TaskMonitorViewModel> Monitors`, keyed by `taskId`.
|
||||||
|
- On open: seed from `GetActive()`. On `TaskStarted`: add a monitor. On `TaskFinished`: keep the
|
||||||
|
pane (so the final output stays readable) but flip its state; a "clear finished" action prunes them.
|
||||||
|
- Adaptive layout signal (column count) from `Monitors.Count`:
|
||||||
|
`1→1col, 2→2col, 3–4→2col(2 rows), 5+→fixed-width panes, horizontal scroll`. Least-active panes
|
||||||
|
beyond a threshold collapse to a compact card (title + last line + chip), click to expand — this is
|
||||||
|
the readability fallback so we never render N unreadable slivers.
|
||||||
|
- Optional `QuickAdd` (deferred within Phase 2): title + target user-list → the **same** creation
|
||||||
|
path as `TasksIslandViewModel.AddAsync` (shared method, not a copy).
|
||||||
|
- Disposes every monitor on window close.
|
||||||
|
|
||||||
|
### Windowing (new plumbing — thin)
|
||||||
|
|
||||||
|
- `MissionControlWindow` (Avalonia `Window`) hosting `MissionControlView`; DataContext =
|
||||||
|
the singleton `MissionControlViewModel`.
|
||||||
|
- No non-modal secondary-window precedent exists (all current dialogs use `ShowDialog(owner)`), so
|
||||||
|
this is genuinely new but small:
|
||||||
|
- Set `desktop.ShutdownMode = OnMainWindowClose` in `App.OnFrameworkInitializationCompleted` so
|
||||||
|
closing Mission Control never quits the app, and closing the main window does.
|
||||||
|
- Open via a **title-bar button in MainWindow** (toggle: show / focus-if-open). The window is
|
||||||
|
created lazily and hidden (not destroyed) on close so its monitors persist cheaply.
|
||||||
|
- Persist size/position (reuse the ui.config.json mechanism if present; otherwise defer).
|
||||||
|
|
||||||
|
### MonitorPaneView (new view, reuses SessionTerminalView)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ #142 Refactor auth module ───────── ● running ─┐ header: title, live chip, tok/turn/elapsed
|
||||||
|
│ ⏱ 4m12s ◆ 18.3k tok ↻ turn 6 │
|
||||||
|
├───────────────────────────────────────────────────┤
|
||||||
|
│ ⚠ Blocked: waiting on #141 (planning parent) │ blocking banner (visible only when blocked)
|
||||||
|
├───────────────────────────────────────────────────┤
|
||||||
|
│ <SessionTerminalView Entries={Log} .../> │ the REUSED console
|
||||||
|
├───────────────────────────────────────────────────┤
|
||||||
|
│ [↗ Open in app] [⧉ Detach] [✕ Cancel] │ footer
|
||||||
|
└───────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation helper "Open in app" (new shell method)
|
||||||
|
|
||||||
|
No select-by-id exists today. Add `IslandsShellViewModel.RevealTaskAsync(taskId)`:
|
||||||
|
1. resolve the task's list, set `Lists.SelectedList`; 2. await `Tasks.LoadForList`; 3. find the row in
|
||||||
|
`Tasks.Items` by id, set `Tasks.SelectedTask` (→ `Details.Bind`); 4. bring MainWindow to front.
|
||||||
|
`TaskMonitorViewModel.OpenInApp` calls this. Single navigation entry point — no duplicate selection logic.
|
||||||
|
|
||||||
|
### Detach (Phase 3)
|
||||||
|
|
||||||
|
`Detach` moves a `TaskMonitorViewModel` out of the grid into a small `TaskMonitorWindow`
|
||||||
|
(reuses `MonitorPaneView`), optionally always-on-top; closing it re-docks. Lowest priority.
|
||||||
|
|
||||||
|
## Risks / open items
|
||||||
|
|
||||||
|
- **Phase 1 binding repoint** is the main risk: a missed `WorkConsole` binding shows as a blank
|
||||||
|
field, not a build error. Mitigation: Ui.Tests + a manual visual pass on the Details pane.
|
||||||
|
- **Localization parity** (Localization.Tests): every new visible string needs en + de keys under a
|
||||||
|
`missionControl.*` namespace.
|
||||||
|
- **Quick-add coupling** across windows is the weakest part; kept optional/deferrable.
|
||||||
|
- Detached windows = most plumbing, least daily payoff → Phase 3, last.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Build `ClaudeDo.App` + run Ui.Tests / Localization.Tests after each phase.
|
||||||
|
- Manual visual pass (cannot be auto-verified): Details pane unchanged after Phase 1; grid populates
|
||||||
|
with 2+ concurrent tasks, blocking banner shows, Open-in-app surfaces the task, adding a task in the
|
||||||
|
main window works while Mission Control is open.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# In-App Interactive Sessions — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
**Status:** Proposed (awaiting approval)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the external Windows-Terminal "Run interactively" session with an **in-app
|
||||||
|
streaming chat**, rendered in the existing `SessionTerminalView` in **both task detail and
|
||||||
|
Mission Control**. Keep everything inside the app — no `wt.exe` pop-out. Autonomous task
|
||||||
|
execution is **untouched** (stays one-shot, non-interactive).
|
||||||
|
|
||||||
|
## Decisions (brainstorm)
|
||||||
|
|
||||||
|
1. **Engine: persistent streaming session.** One `claude` process kept alive with
|
||||||
|
`--input-format stream-json`; user messages pushed over stdin.
|
||||||
|
2. **Scope: interactive sessions only.** The autonomous `TaskRunner`/`ClaudeProcess` run
|
||||||
|
loop, review, queue, and worktree machinery are NOT changed.
|
||||||
|
3. **Placement: shared `SessionTerminalView`** — the in-app session + composer appear in the
|
||||||
|
task-detail session surface and in the Mission Control monitor pane.
|
||||||
|
4. **Full replace.** "Run interactively" now opens the in-app session; the
|
||||||
|
`WindowsTerminalLauncher.LaunchInteractiveAsync` path is removed. **Planning** sessions
|
||||||
|
keep using `wt` (untouched).
|
||||||
|
5. **Send semantics: interrupt + redirect** mid-turn (control protocol), with automatic
|
||||||
|
*queue-for-next-turn* fallback if interrupt is unavailable.
|
||||||
|
|
||||||
|
## What an interactive session is (unchanged semantics, new transport)
|
||||||
|
|
||||||
|
Today (`PlanningSessionManager.OpenInteractiveAsync` + `WindowsTerminalLauncher`):
|
||||||
|
`claude --model <PlanningAlias> --permission-mode auto "<task title+description>"` in the
|
||||||
|
**list's working dir**, env `MAX_THINKING_TOKENS=20000`, full default toolset, relies on the
|
||||||
|
globally-registered `claudedo` MCP. **Ephemeral** — no worktree, no `task_run` record, no
|
||||||
|
status change, no review.
|
||||||
|
|
||||||
|
We keep all of that. Only the transport changes: instead of a `wt` window, the same
|
||||||
|
`claude` invocation runs as a persistent stream-json process owned by the worker, its output
|
||||||
|
streamed into the app and its stdin fed from an in-app composer.
|
||||||
|
|
||||||
|
> Honest tradeoff: the `wt` terminal gave the full Claude Code TUI (slash-command UX,
|
||||||
|
> interactive prompts). An in-app stream-json chat is plainer — type messages, watch streamed
|
||||||
|
> output. `--permission-mode auto` means no blocking permission prompts (so headless works),
|
||||||
|
> but it is a simpler surface than the real TUI. Accepted per the "full replace" decision.
|
||||||
|
|
||||||
|
## The streaming engine
|
||||||
|
|
||||||
|
Flags: `--model <PlanningAlias> --permission-mode auto --input-format stream-json
|
||||||
|
--output-format stream-json --verbose --replay-user-messages` in the list working dir, env
|
||||||
|
`MAX_THINKING_TOKENS=20000`. No `--mcp-config`/`--allowedTools` (interactive uses the global
|
||||||
|
MCP + default tools, exactly as today).
|
||||||
|
|
||||||
|
- First stdin message = the seeded interactive prompt:
|
||||||
|
`{"type":"user","message":{"role":"user","content":[{"type":"text","text":"…"}]},"parent_tool_use_id":null}\n`
|
||||||
|
(stdin stays open).
|
||||||
|
- A stdout read task forwards each NDJSON line to a callback (→ broadcast + the session's log)
|
||||||
|
and detects `result` events (turn boundary; the process then idles for the next message).
|
||||||
|
- `SendUserMessageAsync(text)` writes a user-message JSON line; if a turn is in flight, also
|
||||||
|
`InterruptAsync()` (control-protocol interrupt) so Claude pivots immediately. If interrupt
|
||||||
|
is unavailable, the message lands when the current turn ends → automatic queue fallback.
|
||||||
|
- **Interrupt is verified working** (spike, 2026-06-26, CLI 2.1.191). Exact shape:
|
||||||
|
`{"type":"control_request","request_id":"<id>","request":{"subtype":"interrupt"}}` — no
|
||||||
|
`initialize` handshake needed; `control_response {"subtype":"success"}` confirms
|
||||||
|
synchronously; the same process then accepts the redirect and runs a fresh turn with
|
||||||
|
context intact.
|
||||||
|
- **Interrupt artifact:** the aborted turn emits a `result` with `is_error=true,
|
||||||
|
subtype="error_during_execution"`. The session must treat an interrupt-induced result as
|
||||||
|
*"turn aborted, continue"* (drain the queued redirect), **not** as a session failure.
|
||||||
|
Tolerate the incidental `system:init`/`system:status`/`rate_limit_event`/hook events that
|
||||||
|
also appear in the stream.
|
||||||
|
- `--replay-user-messages` echoes each sent message back on stdout as a `user` event, so it
|
||||||
|
rides the existing stream pipeline into the timeline (ordered + confirmed) with no extra
|
||||||
|
broadcast surface.
|
||||||
|
- The session ends only when the **user stops it** (kill the process tree) — an interactive
|
||||||
|
session has no auto-finalize and never enters review. No queue slot is involved (it is
|
||||||
|
launched directly, not via the autonomous picker).
|
||||||
|
|
||||||
|
## Surface changes
|
||||||
|
|
||||||
|
**Worker**
|
||||||
|
- `Runner/StreamingClaudeSession.cs` (new) — persistent process + send/interrupt/stop; reuse
|
||||||
|
the `ProcessStartInfo` shape + `MCP_TOOL_TIMEOUT` from `ClaudeProcess`; streams via a line
|
||||||
|
callback; `IsTurnInFlight`. Cancellation kills the tree.
|
||||||
|
- `Runner/LiveSessionRegistry.cs` (new, singleton) — `taskId → StreamingClaudeSession`
|
||||||
|
(`Register`/`TryGet`/`Unregister`/`Stop`), mirrors `PendingQuestionRegistry`.
|
||||||
|
- `Planning/InteractiveSessionService.cs` (new) — owns interactive lifecycle: `StartAsync(
|
||||||
|
taskId)` resolves the list working dir + seeded prompt (reuse `OpenInteractiveAsync`'s
|
||||||
|
body), spawns the session, registers it, wires output to `HubBroadcaster.TaskMessage`,
|
||||||
|
broadcasts `InteractiveSessionStarted`; `SendAsync(taskId, text)`; `StopAsync(taskId)` →
|
||||||
|
`InteractiveSessionEnded`.
|
||||||
|
- `Planning/WindowsTerminalLauncher.cs` + `Planning/Interfaces/ITerminalLauncher.cs` — remove
|
||||||
|
`LaunchInteractiveAsync` (+ `InteractiveLaunchContext`). Planning start/resume stay.
|
||||||
|
- `Hub/WorkerHub.cs` — `OpenInteractiveTerminalAsync` re-pointed to
|
||||||
|
`InteractiveSessionService.StartAsync` (no terminal); add `SendInteractiveMessage(taskId,
|
||||||
|
text)`, `StopInteractiveSession(taskId)` (+ optional `InterruptInteractiveSession`).
|
||||||
|
- `Hub/HubBroadcaster.cs` — `InteractiveSessionStarted(taskId)`,
|
||||||
|
`InteractiveSessionEnded(taskId)`. Log lines reuse the existing `TaskMessage(taskId, line)`.
|
||||||
|
- `Program.cs` — register `LiveSessionRegistry` + `InteractiveSessionService`.
|
||||||
|
|
||||||
|
**UI**
|
||||||
|
- `Views/Islands/SessionTerminalView.axaml(.cs)` — add an optional composer (styled
|
||||||
|
properties: `IsComposerVisible`, `ComposerText`, `SubmitCommand`, `ComposerPlaceholder`).
|
||||||
|
Both hosts (task detail + Mission Control) get it by binding their VM's composer state.
|
||||||
|
- `StreamLineFormatter` — render `type:"user"` NDJSON events as a `LogKind.User` bubble.
|
||||||
|
- A small shared composer concept on `TaskMonitorViewModel` **and** `DetailsIslandViewModel`
|
||||||
|
(factor a helper to avoid duplication): `ComposerDraft`, `SubmitComposerCommand`,
|
||||||
|
`IsInteractiveLive` (set by `InteractiveSessionStarted/Ended`). Submit →
|
||||||
|
`SendInteractiveMessageAsync`; clear draft. (If a pending AskUser question exists, the same
|
||||||
|
composer answers it — keep the existing answer route.)
|
||||||
|
- `MissionControlViewModel` — `EnsureMonitor(taskId)` on `InteractiveSessionStarted` so the
|
||||||
|
session appears as a monitor; mark it interactive.
|
||||||
|
- `Services/Interfaces/IWorkerClient.cs` + `WorkerClient.cs` — `SendInteractiveMessageAsync`,
|
||||||
|
`StopInteractiveSessionAsync` (+ optional interrupt); events
|
||||||
|
`InteractiveSessionStartedEvent`/`InteractiveSessionEndedEvent`. `OpenInteractiveTerminalAsync`
|
||||||
|
keeps its name/signature (now starts the in-app session). Update hand-rolled fakes in **both**
|
||||||
|
test projects (`iworkerclient_fakes_sync`).
|
||||||
|
- `TasksIslandViewModel.RunInteractivelyAsync` — unchanged call site; now opens/focuses the
|
||||||
|
in-app session surface instead of a terminal.
|
||||||
|
- Localization `interactive.*` / `missionControl.chat.*` (en/de, parity enforced).
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
- `StreamingClaudeSessionTests` (fake process stream, no real Claude): first message streams;
|
||||||
|
`result` idles; a sent message starts another turn; mid-turn send calls `InterruptAsync`
|
||||||
|
then delivers; interrupt-failure degrades to queue; stop kills.
|
||||||
|
- `LiveSessionRegistryTests` — register/get/unregister/stop.
|
||||||
|
- `InteractiveSessionServiceTests` — start resolves working dir + seeds prompt + registers +
|
||||||
|
broadcasts started; send routes to the session; stop broadcasts ended (fake session +
|
||||||
|
broadcaster).
|
||||||
|
- `TaskMonitorViewModelTests` / `DetailsIslandViewModelTests` — composer enabled while
|
||||||
|
interactive-live; submit invokes client + clears; `user` line renders; question route still
|
||||||
|
answers.
|
||||||
|
|
||||||
|
## Risks / open questions
|
||||||
|
|
||||||
|
- **Interrupt protocol shape — RESOLVED** (spike 2026-06-26, see "The streaming engine").
|
||||||
|
Mid-turn interrupt works on CLI 2.1.191 with the documented shape; the queue fallback is a
|
||||||
|
genuine fallback now, not the expected path. Re-verify if the CLI version changes.
|
||||||
|
- **Plainer than the TUI** — slash-command/interactive-prompt UX differs (accepted).
|
||||||
|
- **Auto-mode editing the list working dir directly** (no worktree) — this is the *existing*
|
||||||
|
interactive behavior, unchanged here.
|
||||||
|
- **No real-Claude tests** (project rule) — the live loop is covered only by the fake stream;
|
||||||
|
real interrupt/redirect is a **manual verification gap** to flag.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Changing autonomous task execution / review / queue / worktrees.
|
||||||
|
- Interactive sessions producing run records, worktrees, or review (stays ephemeral).
|
||||||
|
- Worktree isolation for interactive edits; image/attachment messages in the composer.
|
||||||
|
- Removing planning's `wt` terminal launch.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Session Skills — Design
|
||||||
|
|
||||||
|
**Date:** 2026-07-03
|
||||||
|
**Status:** Approved (design), implementation not started
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Mika wants to give headless task agents a specific Claude skill (e.g.
|
||||||
|
[ponytail](https://github.com/DietrichGebert/ponytail)) **without** installing it
|
||||||
|
globally in `~/.claude/skills/`, where it would leak into every interactive session.
|
||||||
|
Skills should be a first-class, per-level session setting alongside `model`,
|
||||||
|
`max_turns`, and `system_prompt` — configurable **global / per-list / per-task** — and
|
||||||
|
sourced from a GitHub URL.
|
||||||
|
|
||||||
|
## Key facts that shape the design
|
||||||
|
|
||||||
|
- The Claude CLI discovers skills from two places: the **global** `~/.claude/skills/`
|
||||||
|
(every session — undesirable here) and the **working directory's** `.claude/skills/`
|
||||||
|
(plus plugins). ClaudeDo fully controls each spawned session's `WorkingDirectory`
|
||||||
|
(`ClaudeProcess.cs:30`), so a skill dropped into the session cwd is scoped to exactly
|
||||||
|
that headless run.
|
||||||
|
- **Auto-commit uses `git add -A`** (`WorktreeManager.CommitIfChangedAsync` →
|
||||||
|
`_git.AddAllAsync`, `WorktreeManager.cs:142`). Anything seeded into a worktree's
|
||||||
|
`.claude/skills/` would be committed unless explicitly excluded → the seeder must add
|
||||||
|
the seeded paths to the worktree's `info/exclude`.
|
||||||
|
- A skill is not just prompt text: `SKILL.md` may reference scripts that run via Bash.
|
||||||
|
Headless agents run with `--permission-mode auto` (effectively unattended), so a skill
|
||||||
|
pulled from an arbitrary URL is **unattended third-party code execution**. This is why
|
||||||
|
install is a deliberate, pinned, reviewable step — not a live per-run URL fetch.
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
1. **Install-and-pin, not live fetch.** A dedicated registry screen installs a skill
|
||||||
|
once: clone the repo, pin to the current commit, store locally. Per-level config then
|
||||||
|
references installed skills **by name** (checkboxes), never a URL.
|
||||||
|
2. **Three levels, additive union.** Effective skill set = `global ∪ list ∪ task`.
|
||||||
|
(Unlike `model`/`prompt`, which override — skills add up. Trade-off accepted: an
|
||||||
|
inherited skill can't be switched off for a single task in the MVP.)
|
||||||
|
3. **A repo can contribute multiple skills.** Installer detects the layout:
|
||||||
|
- `skills/*/SKILL.md` (plugin bundle) → import **each** subskill flat. This is
|
||||||
|
ponytail: it ships 6 skills (`ponytail`, `-help`, `-review`, `-audit`, `-debt`,
|
||||||
|
`-gain`) under `skills/<name>/SKILL.md` plus `.claude-plugin/`, hooks, commands, an
|
||||||
|
MCP — none of which we consume; we take only the `skills/<name>/` dirs.
|
||||||
|
- root `SKILL.md` → single skill.
|
||||||
|
- neither → reject.
|
||||||
|
The CLI expects `.claude/skills/<name>/SKILL.md` **flat**, so subskills are flattened
|
||||||
|
on install. Selection is **per individual skill name** (enable just `ponytail` +
|
||||||
|
`ponytail-help` if you want, not all six).
|
||||||
|
4. **Public repos only** (plain `git clone` over HTTPS, no auth) for MVP.
|
||||||
|
|
||||||
|
> **Correction (2026-07-03, from the smoke test):** the original "one repo = one skill
|
||||||
|
> at root" MVP was wrong for the very target repo — ponytail is a multi-skill plugin.
|
||||||
|
> Decision 3 above replaces it.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Storage & registry
|
||||||
|
|
||||||
|
- Each discovered skill is copied **flat** to `~/.todo-app/session-skills/<name>/` (its
|
||||||
|
own self-contained dir with `SKILL.md` at the root of that dir), so the seeder just
|
||||||
|
copies `<name>/` → cwd.
|
||||||
|
- New DB table `session_skills`, one row **per skill** (a multi-skill repo writes N rows
|
||||||
|
sharing `source_url` + `pinned_ref`): `name` (PK), `source_url`, `pinned_ref` (commit
|
||||||
|
SHA), `subpath` (dir within the repo the skill came from, e.g. `skills/ponytail` or
|
||||||
|
`.` for root), `description`, `added_at`. Repo-level ops act on all rows with the same
|
||||||
|
`source_url` (no separate sources table — keep it flat).
|
||||||
|
- New worker service `SessionSkillRegistry` (in a new `Skills/` area under the Worker):
|
||||||
|
- `InstallAsync(url)` — clone to temp → **detect layout** (`skills/*/SKILL.md` bundle
|
||||||
|
vs root `SKILL.md`) → for each discovered skill parse frontmatter (`name`,
|
||||||
|
`description`), resolve HEAD SHA as `pinned_ref`, copy its dir flat into place, upsert
|
||||||
|
a row. Returns the list of installed skill names. Name collision (a skill name from a
|
||||||
|
*different* source) → error surfaced to UI; reinstalling the same source updates.
|
||||||
|
Clone is injected (`IRepoCloner`) so tests use a local source dir — **no real network,
|
||||||
|
no real CLI**.
|
||||||
|
- `UpdateAsync(sourceUrl)` — re-clone, re-detect, refresh that source's skills +
|
||||||
|
`pinned_ref`.
|
||||||
|
- `RemoveAsync(sourceUrl)` — delete all its skill dirs + rows.
|
||||||
|
- `ListAsync()` — registry entries for the UI (grouped by source for display).
|
||||||
|
|
||||||
|
### Resolution
|
||||||
|
|
||||||
|
`TaskRunner.ResolveConfigAsync` (`TaskRunner.cs:488`) already merges
|
||||||
|
task → list → global for the other fields. Add:
|
||||||
|
|
||||||
|
```
|
||||||
|
SkillNames = Union(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills)
|
||||||
|
```
|
||||||
|
|
||||||
|
deduped, filtered to names that still exist in the registry (a removed skill is silently
|
||||||
|
dropped — logged). Add `IReadOnlyList<string> SkillNames` to `ClaudeRunConfig`
|
||||||
|
(`ClaudeArgsBuilder.cs:5`). **No CLI flag is emitted** — skills are seeded on disk, not
|
||||||
|
passed as args. `ClaudeArgsBuilder.Build` is unchanged for skills.
|
||||||
|
|
||||||
|
Per-level storage: a nullable TEXT column `session_skills` (JSON array of names) on
|
||||||
|
`tasks`, `list_config`, and `app_settings`.
|
||||||
|
|
||||||
|
### Seeding
|
||||||
|
|
||||||
|
New service `SessionSkillSeeder`, called by `TaskRunner` after the working dir is
|
||||||
|
resolved and before `ClaudeProcess.RunAsync`:
|
||||||
|
|
||||||
|
- For each resolved skill name, copy `~/.todo-app/session-skills/<name>/` →
|
||||||
|
`<cwd>/.claude/skills/<name>/` (overwrite → idempotent for resume/re-run).
|
||||||
|
- If the cwd is a git worktree, append `/.claude/skills/<name>/` to the worktree's
|
||||||
|
`info/exclude` (path via `git rev-parse --git-path info/exclude`, so it targets the
|
||||||
|
per-worktree exclude), only if not already present. **Only the seeded subdirs are
|
||||||
|
excluded** — never blanket-exclude `/.claude/`, in case the target project commits its
|
||||||
|
own `.claude/`.
|
||||||
|
- Sandbox runs (not a repo) skip the exclude step.
|
||||||
|
- No separate cleanup: seeded dirs vanish with the worktree/sandbox.
|
||||||
|
|
||||||
|
### allowedTools caveat
|
||||||
|
|
||||||
|
`--allowedTools` is only emitted when set (`ClaudeArgsBuilder.cs:80`); normal task runs
|
||||||
|
leave it null → all tools allowed → the `Skill` tool is available. If a future per-task
|
||||||
|
allowedTools restriction is added, it must include `Skill`. Noted, not handled in MVP.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
Mirror the existing agent-file pattern.
|
||||||
|
|
||||||
|
- **Registry screen ("extra mask"):** a new **Skills** tab in the Settings modal
|
||||||
|
(`SettingsModalView.axaml`) with `SessionSkillsSettingsTabViewModel`. **Add** (URL text
|
||||||
|
box → install a repo, which may yield several skills); lists installed skills grouped by
|
||||||
|
source (name, description, source, short ref); **Update** / **Remove** act per source
|
||||||
|
(repo). Status/error line like `FilesSettingsTabViewModel`.
|
||||||
|
- **Global selector:** multi-select (checkbox list) of installed skills in the General
|
||||||
|
settings tab → `AppSettings.SessionSkills`.
|
||||||
|
- **List + Task selectors:** add a skills multi-select to the shared
|
||||||
|
`AgentConfigEditor` control (`AgentConfigEditor.axaml` /
|
||||||
|
`AgentConfigEditorViewModel.cs`), which is already reused by both List settings and the
|
||||||
|
per-task flyout — one addition covers both levels, with the existing inheritance-badge
|
||||||
|
pattern.
|
||||||
|
|
||||||
|
### Hub / client surface
|
||||||
|
|
||||||
|
New `WorkerHub` methods + `IWorkerClient` entries (update hand-rolled fakes in both test
|
||||||
|
projects — see memory `iworkerclient_fakes_sync`):
|
||||||
|
`GetSessionSkills`, `InstallSessionSkill(url)`, `UpdateSessionSkill(sourceUrl)`,
|
||||||
|
`RemoveSessionSkill(sourceUrl)`. Extend `AppSettingsDto`, `ListConfigDto`,
|
||||||
|
`UpdateListConfigDto`, `UpdateTaskAgentSettingsDto` with the selected skill-name lists.
|
||||||
|
New `SessionSkillDto`.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- **Removed skill still referenced** by a level → dropped at resolve time, logged, no
|
||||||
|
failure.
|
||||||
|
- **Name collision on install** → reject with a clear message; offer Update instead.
|
||||||
|
- **Repo without root `SKILL.md`** → reject at install.
|
||||||
|
- **Target project already has `.claude/skills/`** → additive copy; exclude only our
|
||||||
|
subdirs.
|
||||||
|
- **Resume / re-run** reuses the worktree → re-seed overwrites, exclude append is
|
||||||
|
idempotent.
|
||||||
|
|
||||||
|
## Verification (must-check, can't be unit-tested)
|
||||||
|
|
||||||
|
- **Does `claude -p` actually load and invoke a skill placed in cwd `.claude/skills/`?**
|
||||||
|
**CONFIRMED 2026-07-03.** Mika ran, in a stable terminal, a `claude -p` invocation in a
|
||||||
|
scratch cwd holding `.claude/skills/ponytail*/SKILL.md`; the model invoked the
|
||||||
|
`ponytail-help` skill and returned its exact Lite/Full/Ultra table. Headless mode does
|
||||||
|
surface cwd skills → the whole approach holds; no `CLAUDE_CONFIG_DIR` fallback needed.
|
||||||
|
- Seeded skill is **not** committed by the auto-commit step (worktree run).
|
||||||
|
- Skill does not appear in a normal interactive session (no global leak).
|
||||||
|
|
||||||
|
## Out of scope (MVP)
|
||||||
|
|
||||||
|
Private-repo auth; consuming a plugin's *non-skill* parts (hooks, commands, MCP — we take
|
||||||
|
only `skills/<name>/`); auto-update & update notifications; per-task *disabling* of an
|
||||||
|
inherited skill; surfacing skill invocation in the run log.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# ConPTY Interactive Sessions — Design
|
||||||
|
|
||||||
|
Date: 2026-07-23
|
||||||
|
Status: Approved (design), implementation not started
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The current in-app interactive session path streams `stream-json` from a
|
||||||
|
`claude` process spawned **in the Worker** and renders it as a chat log with a
|
||||||
|
composer. It does not surface permission requests, AskUser questions, and other
|
||||||
|
TUI-native interactions well — the rendering is a partial reimplementation of
|
||||||
|
what the real Claude Code TUI already does. We want full fidelity for
|
||||||
|
interactive work without rebuilding the TUI.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Hybrid execution model:**
|
||||||
|
|
||||||
|
- **Autonomous queue tasks** (`Status=Queued`, picked by the queue): unchanged.
|
||||||
|
Headless `stream-json`, full orchestration (status flow, diff, review, merge).
|
||||||
|
- **Interactive sessions**: an **embedded ConPTY terminal** running the real
|
||||||
|
`claude` CLI, rendered in the **UI process**. Full TUI fidelity (permission
|
||||||
|
prompts, questions, colors, everything the standalone CLI does). Detached from
|
||||||
|
the review/merge/status machinery — these are a manual cockpit.
|
||||||
|
|
||||||
|
This is the third direction for interactive (external `wt` terminal → streaming
|
||||||
|
chat → embedded ConPTY). The streaming interactive stack is **removed**, not run
|
||||||
|
in parallel — accepted as discarded work in exchange for one interactive path
|
||||||
|
and full fidelity.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Process location
|
||||||
|
|
||||||
|
ConPTY terminal controls render in-process and spawn their child (`claude`) as a
|
||||||
|
child of the host process. Therefore interactive sessions move **out of the
|
||||||
|
Worker and into the UI process**. They no longer flow over SignalR. This mirrors
|
||||||
|
the existing `ResumeTaskInTerminal` behavior (launch real `claude`), but embedded
|
||||||
|
instead of via external `wt.exe`.
|
||||||
|
|
||||||
|
### Worktree preparation (task-based sessions)
|
||||||
|
|
||||||
|
Interactive task sessions get the **same worktree preparation as autonomous
|
||||||
|
runs**: session-skills seeding, agent files, MCP config, environment. The Worker
|
||||||
|
performs the prep and returns a launch spec to the UI:
|
||||||
|
|
||||||
|
```
|
||||||
|
LaunchSpec {
|
||||||
|
cwd: string // worktree path
|
||||||
|
exe: string // resolved claude executable / shell
|
||||||
|
args: string[] // e.g. --resume <sessionId>
|
||||||
|
env: Dictionary<string,string>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The command construction reuses `WindowsTerminalLauncher.BuildResumeArgs`/`Resolve`.
|
||||||
|
Guards mirror `ResumeTaskInTerminal` for Running/Queued (rejected). Worktree
|
||||||
|
handling (FINAL):
|
||||||
|
- Existing Active/Kept worktree + persisted SessionId → `--resume <id>`.
|
||||||
|
- No usable worktree but the list has a WorkingDir (git repo) → create a worktree
|
||||||
|
**on demand** via `WorktreeManager.CreateAsync` (the same path autonomous runs
|
||||||
|
use), then a fresh-start spec. This lets never-run tasks be opened interactively.
|
||||||
|
- Fresh (non-resume) session → the task's prompt (title + description) is passed
|
||||||
|
as claude's positional prompt so the session starts on the task.
|
||||||
|
- No worktree and no WorkingDir → clear error.
|
||||||
|
Interactive sessions are detached: ClaudeDo does NOT record their claude session
|
||||||
|
id (ConPTY is opaque, no stream-json), so resuming a specific past interactive
|
||||||
|
conversation is only via claude's own `--continue`/`--resume` in the worktree dir.
|
||||||
|
|
||||||
|
### Free / ad-hoc sessions
|
||||||
|
|
||||||
|
In addition to task-based sessions, the user can open an ad-hoc terminal in a
|
||||||
|
chosen directory (no task). These also get MCP config + env set up so the
|
||||||
|
`claudedo` tools are available, but no per-level session-skills seeding tied to a
|
||||||
|
task.
|
||||||
|
|
||||||
|
### Terminal host control — RESOLVED by spike (2026-07-23)
|
||||||
|
|
||||||
|
**Library: `Iciclecreek.Avalonia.Terminal` 2.0.3** (namespace `Iciclecreek.Terminal`).
|
||||||
|
It needs Avalonia >= 12.0.2; the repo is on 12.0.4 → compatible, no bump.
|
||||||
|
`SvcSystems.UI.Terminal` (latest) needs Avalonia 12.1+ → rejected.
|
||||||
|
|
||||||
|
Visual pass (user, 2026-07-23): the real `claude` TUI renders correctly inside
|
||||||
|
the embedded control — Claude Code opened and was usable.
|
||||||
|
|
||||||
|
**Binding approach — use the library's own `LaunchProcess()` (FINAL).**
|
||||||
|
An initial attempt drove `Porta.Pty` ourselves (own read loop, key tunneling via
|
||||||
|
`GenerateKeyInput`/`GenerateCharInput`, manual resize) to bypass
|
||||||
|
`LaunchProcess()` and inject a custom `PtyOptions.Environment`. That was a
|
||||||
|
mistake: it rendered wrong, lagged, and dropped input. The spike had proven the
|
||||||
|
library's own `TerminalControl.LaunchProcess()` pipeline renders correctly, stays
|
||||||
|
responsive, and handles input/resize/focus. So `PtyTerminalSession` is a thin
|
||||||
|
wrapper (`src/ClaudeDo.Ui/Services/PtyTerminalSession.cs`):
|
||||||
|
|
||||||
|
- Set `control.Process = descriptor.Exe`, `control.Args = descriptor.Args`,
|
||||||
|
`control.StartingDirectory = descriptor.Cwd`, then `await control.LaunchProcess()`.
|
||||||
|
- Relay the control's own `ProcessExited` event and `Kill()`.
|
||||||
|
- `Process=""` stays on the AXAML `TerminalControl` to suppress the library's
|
||||||
|
auto-launch-on-load, so exactly one process starts (our manual launch).
|
||||||
|
|
||||||
|
**Environment:** `LaunchProcess()` gives no per-launch env dict, but
|
||||||
|
`Porta.Pty.SpawnAsync` inherits the *current process* environment. So apply
|
||||||
|
`descriptor.Env` entries via `Environment.SetEnvironmentVariable(key, value)`
|
||||||
|
(process scope) before `LaunchProcess()`. The only var needed is
|
||||||
|
`MCP_TOOL_TIMEOUT`; session-skills/agent-files/MCP-config are on disk / globally
|
||||||
|
registered, independent of env. (The earlier "child gets zero env vars" claim was
|
||||||
|
wrong — Porta.Pty seeds from the process env.)
|
||||||
|
|
||||||
|
**Sizing gotcha (critical):** the control derives Cols/Rows from
|
||||||
|
`arranged-size / character-cell-size`. Without an EXPLICIT monospace font the cell
|
||||||
|
metrics are wrong and the child TUI renders into the wrong area. Set
|
||||||
|
`FontFamily="Cascadia Mono,Consolas,monospace"`, `FontSize`, `BufferSize`, and
|
||||||
|
`HorizontalAlignment/VerticalAlignment=Stretch` on the `TerminalControl` (matching
|
||||||
|
the spike) — this is what made rendering correct in the pane.
|
||||||
|
|
||||||
|
**Lesson:** do not hand-roll pty/input/render around this control — use its
|
||||||
|
`LaunchProcess()` pipeline.
|
||||||
|
|
||||||
|
### Command Center layout
|
||||||
|
|
||||||
|
- `MonitorPaneView` keeps the streamed log for autonomous tasks.
|
||||||
|
- Interactive panes host the terminal control instead of the log+composer.
|
||||||
|
- Layout is **toggleable**: focus mode (tabs, one session large) ↔ overview mode
|
||||||
|
(grid, several sessions at once).
|
||||||
|
|
||||||
|
## Removals
|
||||||
|
|
||||||
|
Worker:
|
||||||
|
- `StreamingClaudeSession`, `InteractiveSessionService`
|
||||||
|
- `WorkerHub` interactive methods: `OpenInteractiveTerminal`,
|
||||||
|
`SendInteractiveMessage`, `RemoveQueuedInteractiveMessage`,
|
||||||
|
`StopInteractiveSession`, `InterruptInteractiveSession`
|
||||||
|
- Broadcast events: `InteractiveSessionStarted/Ended`, `InteractiveQueueChanged`,
|
||||||
|
`InteractiveMessageSent`
|
||||||
|
- `IdleSessionReaper` and `LiveSessionRegistry` **iff** unused elsewhere
|
||||||
|
(verify during implementation — do not delete blindly).
|
||||||
|
|
||||||
|
UI:
|
||||||
|
- Composer on `TaskMonitorViewModel`: `ComposerDraft`, `SubmitComposerCommand`,
|
||||||
|
`InterruptInteractiveCommand`, `StopInteractiveCommand`, `QueuedMessages`,
|
||||||
|
`IsInteractiveLive`.
|
||||||
|
- The composer + queued-messages portion of `SessionTerminalView` (the log
|
||||||
|
portion stays for autonomous panes).
|
||||||
|
- `IWorkerClient` interactive methods.
|
||||||
|
|
||||||
|
## Open items (implementation time)
|
||||||
|
|
||||||
|
- Whether `LiveSessionRegistry` is referenced outside the interactive path.
|
||||||
|
- Session-id availability for a never-run task (no `--resume` → start fresh).
|
||||||
|
|
||||||
|
Resolved: env approach (see Terminal host control — add custom vars on top of the
|
||||||
|
inherited process env via `PtyOptions.Environment`); library + binding seam.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No screen-scraping of terminal output back into task status/diff/review.
|
||||||
|
- No change to the autonomous queue execution path.
|
||||||
|
|
||||||
|
## Status (2026-07-23)
|
||||||
|
|
||||||
|
Implemented on main (not pushed) and visually verified by the user (rendering,
|
||||||
|
input, responsiveness correct after switching to `LaunchProcess()` + setting a
|
||||||
|
monospace font). Done: worker launch-spec (task + ad-hoc, on-demand worktree,
|
||||||
|
prompt seeding), UI terminal host, Command Center hosting (grid↔tabs), streaming
|
||||||
|
stack removed. Permission mode: left to claude's default (not forced), per user.
|
||||||
|
|
||||||
|
Deferred: **Avalonia 12.1 upgrade** — blocked, not adopted. 12.1's XAML source
|
||||||
|
generator needs Roslyn 4.14 (.NET 9.0.3xx SDK); the repo pins .NET 8 in
|
||||||
|
`global.json`, and bumping the SDK floor risks the Gitea Actions release build.
|
||||||
|
Staying on Avalonia 12.0.x (Iciclecreek 2.0.3 works there). Revisit only with a
|
||||||
|
deliberate SDK-floor decision.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# ConPTY Planning Sessions — Design (2026-07-24)
|
||||||
|
|
||||||
|
**Task:** `5d627df8` — interaktive Planning-Session über embedded ConPTY statt externem `wt`-Fenster.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Planning-Sessions (`StartPlanningSession`/`ResumePlanningSession`) öffnen heute ein externes
|
||||||
|
Windows-Terminal (`WindowsTerminalLauncher` → `wt.exe`). Seit ConPTY existiert (embedded
|
||||||
|
claude-TUI im UI-Prozess, Command Center), soll die interaktive Planning-Session denselben
|
||||||
|
Weg nutzen: konsistente UX, keine externen Fenster.
|
||||||
|
|
||||||
|
## Approved decisions (Brainstorm 2026-07-24)
|
||||||
|
- **Env-Isolation:** Prozess-global akzeptiert (Sessions werden sequenziell per Klick geöffnet;
|
||||||
|
jedes claude-Child snapshottet Env beim Spawn). Keine Änderung an `PtyTerminalSession`.
|
||||||
|
- **wt-Launcher:** Code bleibt; nur das Routing für Planning wird auf ConPTY umgestellt
|
||||||
|
(kein Rip-out von `LaunchPlanningStart/ResumeAsync`).
|
||||||
|
- Planning-Kachel im Command Center (Mission Control), wie andere ConPTY-Sessions.
|
||||||
|
Finalize/Discard/Queue-Plan bleiben unverändert auf den bestehenden Buttons.
|
||||||
|
|
||||||
|
## Change map
|
||||||
|
|
||||||
|
### Worker
|
||||||
|
1. `WindowsTerminalLauncher`: Planning-Start-Args in einen bare Builder
|
||||||
|
`BuildPlanningStartArgs(PlanningSessionStartContext) -> IReadOnlyList<string>` herausziehen
|
||||||
|
(analog `BuildResumeArgs`); `BuildPlanningStartCommand` nutzt ihn weiter (wt unverändert).
|
||||||
|
Resume-Args `BuildPlanningResumeArgs(sessionId) = ["--permission-mode","plan","--resume",id]`
|
||||||
|
(bisher inline in `LaunchPlanningResumeAsync`).
|
||||||
|
2. `InteractiveLaunchSpecService`: `BuildPlanningStart(PlanningSessionStartContext) -> LaunchSpec`
|
||||||
|
und `BuildPlanningResume(PlanningSessionResumeContext) -> LaunchSpec` — resolve claude,
|
||||||
|
Args aus (1), Env `MAX_THINKING_TOKENS=20000` + `CLAUDEDO_PLANNING_TOKEN=<token>`
|
||||||
|
(+ `MCP_TOOL_TIMEOUT` wie interaktiv), Cwd = ctx.WorkingDir. Nimmt den Kontext (keine
|
||||||
|
Manager-Kopplung).
|
||||||
|
3. Hub: `GetPlanningStartLaunchSpec(taskId) -> LaunchSpec` (ruft `_planning.StartAsync`,
|
||||||
|
broadcastet `TaskUpdated`, baut Spec; bei Fehler discard+rethrow wie heute),
|
||||||
|
`GetPlanningResumeLaunchSpec(taskId) -> LaunchSpec` (ruft `_planning.ResumeAsync`).
|
||||||
|
|
||||||
|
### UI
|
||||||
|
4. `IWorkerClient`/`WorkerClient`: `GetPlanningStartLaunchSpecAsync`/`GetPlanningResumeLaunchSpecAsync`
|
||||||
|
(mirror `GetInteractiveLaunchSpecAsync`).
|
||||||
|
5. `MissionControlViewModel`: `OpenPlanningConPtySessionAsync(taskId, resume)` — dedupt nach
|
||||||
|
TaskId, holt die Planning-Spec, baut `TerminalLaunchDescriptor` → `ConPtyPaneViewModel`
|
||||||
|
(Titel „<task> (Planning)").
|
||||||
|
6. `TasksIslandViewModel`: `OpenPlanningSessionAsync`/`ResumePlanningSessionAsync` (Resume-Zweig
|
||||||
|
der `UnfinishedPlanningModal`) öffnen die Planning-ConPTY-Kachel via neuem Event
|
||||||
|
`OpenPlanningConPtyRequested(taskId, resume)`, statt `StartPlanningSessionAsync`/wt.
|
||||||
|
`IslandsShellViewModel` verdrahtet das Event → `OpenMissionControl()` +
|
||||||
|
`MissionControl.OpenPlanningConPtySessionAsync`.
|
||||||
|
|
||||||
|
### #12 (Nebenbefund)
|
||||||
|
MCP-Permission-Prompt trotz `--allowedTools "mcp__claudedo__*"`: im interaktiven ConPTY vom
|
||||||
|
User bestätigbar (kein Blocker wie headless). Beim Testen prüfen, ob der Glob den Prompt in
|
||||||
|
der aktuellen CLI unterdrückt; falls nicht, allowedTools/`--permission-mode`-Kombi nachziehen.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
- Entfernen des wt-Codes / `ResumeTaskInTerminal`.
|
||||||
|
- Per-Child-Env-Isolation in `PtyTerminalSession`.
|
||||||
|
- Änderungen an Finalize/Discard/Queue-Plan-Lifecycle.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Build Worker + App (`-c Release`), Worker.Tests + Ui.Tests grün.
|
||||||
|
- Visual (User): Planning-Start öffnet Command-Center-Kachel mit claude-TUI im Plan-Modus;
|
||||||
|
create_child_task erzeugt Draft-Kinder live; Resume greift die Session; kein wt-Fenster.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Merge Helper ("Let Claude handle it") — Design
|
||||||
|
|
||||||
|
**Status:** Proposed — awaiting approval
|
||||||
|
**Date:** 2026-07-24
|
||||||
|
**Scope:** Feature — a per-list and global button that opens an **interactive ConPTY Claude session** pre-loaded with a set of user-selected tasks. The session (the "Merge Helper") drives each selected task to completion and merge autonomously via `mcp__claudedo__*` tools, asks the user interactively (in the ConPTY terminal) only when uncertain, resolves merge conflicts itself, and ends with a written summary of everything that changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Collapse the repetitive per-task review→merge clicking into a single "Let Claude handle it" action. The user picks the tasks; an embedded Claude session babysits them — running the ones that still need running, reviewing diffs, merging the clean ones, resolving conflicts, and reporting back — while remaining fully interactive so the user can answer questions mid-run.
|
||||||
|
|
||||||
|
This reuses the existing ConPTY infrastructure (UI-process embedded terminal) and the globally-registered `claudedo` MCP server. The only genuinely new worker capability is **MCP-driven conflict resolution** (§5), which today exists only in the UI hub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decisions (locked with user, 2026-07-24)
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Which tasks does the helper handle? | **Free choice, any status.** User hand-picks; helper acts per-status. |
|
||||||
|
| How are tasks selected for a run? | **Checkbox dialog before launch** (candidates listed, user ticks). |
|
||||||
|
| Merge authority / review-gate | **Auto-merge; asks interactively on uncertainty.** The app's per-task diff-gate is intentionally bypassed for helper-driven merges. |
|
||||||
|
| Conflict handling | **Build MCP conflict tools** so the helper resolves conflicts in the working tree itself, asking only when unsure (Option B). The helper must handle **all** cases including parent/children unit merges; where the MCP path doesn't reach, **manual resolution by hand (Edit + git) is an accepted fallback** (user-confirmed 2026-07-24). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UX Flow
|
||||||
|
|
||||||
|
1. **Entry points**
|
||||||
|
- **Per-list:** context-menu item **"Let Claude handle it"** on each user-list row in `ListsIslandView.axaml` (alongside Settings / Worktrees / Open in Explorer).
|
||||||
|
- **Global:** one entry (footer of the lists island) that spans *all* lists/repos.
|
||||||
|
2. Click opens the **Merge Helper selection dialog** (§4): a checkbox list of candidate tasks, grouped by list/repo, pre-filtered to tasks worth acting on but freely overridable.
|
||||||
|
3. User ticks tasks → **"Let Claude handle it"** confirm button.
|
||||||
|
4. UI asks the worker for a `MergeHelperLaunchSpec`, opens a **ConPTY tile in Mission Control** running the real `claude` TUI with the merge-helper prompt.
|
||||||
|
5. The session works through the tasks, printing progress and asking questions inline; the user answers directly in the terminal.
|
||||||
|
6. On completion Claude prints a **summary** (merged / skipped / conflicted / follow-ups). The tile stays open for review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Selection Dialog
|
||||||
|
|
||||||
|
New modal `MergeHelperSelectionDialog` (View + VM), built with the existing `TaskCompletionSource<T>` dialog pattern used by other modals.
|
||||||
|
|
||||||
|
**Contents:**
|
||||||
|
- Title: *"Let Claude handle it"* + subtitle naming the scope ("List: <name>" or "All lists").
|
||||||
|
- A scrollable checkbox list of **candidate tasks**. Per row: checkbox, title, status badge, list/repo name (in global mode).
|
||||||
|
- Grouping: by list/repo in global mode; flat in per-list mode.
|
||||||
|
- Default selection: all **actionable** tasks pre-ticked — actionable = `WaitingForReview`, `Idle`, `Queued`, `Failed` (resettable). `Running` / `WaitingForChildren` shown but unticked (helper will poll them). Terminal `Done`/`Cancelled` excluded from the list entirely.
|
||||||
|
- Footer: **"Let Claude handle it"** (disabled when nothing ticked) + **Cancel**. A "select all / none" affordance.
|
||||||
|
|
||||||
|
**Candidate source:** `list_tasks` via the existing worker client (per list, or across all lists for global). No new query needed; the VM filters client-side by status.
|
||||||
|
|
||||||
|
**Output:** an ordered `IReadOnlyList<string>` of selected task IDs (+ their list/repo mapping), passed to the launch request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. New Worker Capability — MCP Conflict Resolution
|
||||||
|
|
||||||
|
Today (verified): `merge_task` / `review_task approve` call `TaskMergeService.MergeAsync(..., leaveConflictsInTree:false)` — on conflict they run `git merge --abort` (clean rollback, no markers) and return `mergeStatus="conflict"`; the task stays `WaitingForReview`. Continue/abort/write-resolution exist **only** on the SignalR hub (Rider merge editor). An MCP agent therefore cannot resolve conflicts. This section adds that.
|
||||||
|
|
||||||
|
### 5.1 Approach
|
||||||
|
|
||||||
|
Reuse the *exact* engine methods the UI already uses — `TaskMergeService.MergeAsync(leaveConflictsInTree:true)`, `ContinueMergeAsync`, `AbortMergeAsync` — and expose them over MCP. The helper resolves conflict markers on disk (it has filesystem access to the repo checkouts via `--add-dir`, see §6.3) and drives the merge state exclusively through MCP tools so the engine stays authoritative.
|
||||||
|
|
||||||
|
### 5.2 MCP surface changes (`ExternalMcpService.cs`)
|
||||||
|
|
||||||
|
1. **`review_task` / `merge_task` — new optional param `leaveConflictsInTree: bool = false`.**
|
||||||
|
When `true` and the merge conflicts: leave markers in the checkout instead of aborting, and return
|
||||||
|
`{ mergeStatus: "conflict_in_tree", conflicts: string[], repoPath: string }`
|
||||||
|
where `repoPath` is the checkout holding the markers. Task stays `WaitingForReview`, merge is in progress. Clean-merge behaviour is unchanged, so the helper can always pass `true`.
|
||||||
|
|
||||||
|
2. **New tool `continue_merge(taskId)`** → `TaskMergeService.ContinueMergeAsync`.
|
||||||
|
Stages the resolved files and commits the merge; on success the task goes to `Done` and the worktree is marked merged, returning `{ merged: true, mergeCommit }`. If markers remain, returns `{ merged: false, conflicts: string[] }`.
|
||||||
|
|
||||||
|
3. **New tool `abort_merge(taskId)`** → `TaskMergeService.AbortMergeAsync`.
|
||||||
|
Aborts the in-progress merge; task stays `WaitingForReview`. Returns `{ aborted: true }`.
|
||||||
|
|
||||||
|
**Implementation notes (verify against `TaskMergeService.cs` during the plan):**
|
||||||
|
- Confirm the exact signatures of `ContinueMergeAsync` / `AbortMergeAsync` and how in-progress-merge state is keyed. The hub tracks a single active conflict merge; the MCP variants must locate the merge from `taskId` (target branch + repo from the task/list), not shared hub state.
|
||||||
|
- Emit the existing `TaskUpdated` event after continue/abort so the UI list re-buckets live.
|
||||||
|
- Guard against a repo already mid-merge (`Blocked`) — surface it to the agent rather than clobbering.
|
||||||
|
|
||||||
|
### 5.3 Parent/children unit merges
|
||||||
|
|
||||||
|
`review_task approve` on a task **with children** drives `PlanningMergeOrchestrator` (a multi-step unit merge with its own continue/abort on the hub). The helper must handle these too. Two paths, tried in order:
|
||||||
|
|
||||||
|
1. **MCP (preferred):** `continue_merge` / `abort_merge` detect *which* kind of in-progress merge the task has (single-task `TaskMergeService` vs orchestrated `PlanningMergeOrchestrator`) and route to the matching engine continue/abort. This keeps the orchestrated path engine-mediated over MCP too. Implement if the orchestrator's continue/abort can be located from the task without shared hub UI state (verify in A1).
|
||||||
|
2. **Manual fallback (accepted):** where the MCP path genuinely can't reach an in-progress merge, the helper resolves the conflict markers on disk (Read/Edit) and completes the merge by hand (`git add <paths>` + `git commit`, or `git merge --continue`). The user has explicitly accepted hand-merging as a fallback. The system prompt still mandates: **prefer the MCP tools whenever they apply**; only drop to raw git for cases the MCP tools don't cover, and honour the shared-checkout rule (`git commit -- <paths>`, never a bare commit that sweeps peers' index).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Launch — Worker + Wiring
|
||||||
|
|
||||||
|
### 6.1 Prompt templates
|
||||||
|
|
||||||
|
Add `PromptKind.MergeHelper` (system) and `PromptKind.MergeHelperInitial` (brief) to `ClaudeDo.Data/PromptFiles.cs`, with built-in defaults and `{{token}}` rendering, mirroring `Planning` / `PlanningInitial`.
|
||||||
|
|
||||||
|
- **System prompt** (`merge-helper-system.md`): defines the role and the per-status algorithm (§7), the merge/conflict rules, the "ask on uncertainty" posture, and the required final summary format.
|
||||||
|
- **Initial brief** (`merge-helper-initial.md`): rendered with the selected tasks — a table of `{id, title, status, list, repo}` plus the scope label. Written to a session-brief file on disk; the positional prompt is a **single-line kickoff** pointing at that file via `--add-dir` (planning pattern — a multi-line positional prompt truncates at the first newline).
|
||||||
|
|
||||||
|
### 6.2 Session files
|
||||||
|
|
||||||
|
Path: `~/.todo-app/merge-helper-sessions/<sessionId>/` (a fresh GUID per run — these sessions are ephemeral and never resumed):
|
||||||
|
- `brief.md` — rendered task list + scope + instructions.
|
||||||
|
- No per-session MCP config: the session uses the **globally-registered `claudedo` MCP server** (same as task/ad-hoc sessions), so no token is needed.
|
||||||
|
|
||||||
|
Cleanup: prune session dirs older than N days on app start (best-effort; same posture as planning dirs).
|
||||||
|
|
||||||
|
### 6.3 Launch spec
|
||||||
|
|
||||||
|
New `InteractiveLaunchSpecService.BuildForMergeHelper(selectedTaskIds, scope, ct)` returning a `LaunchSpec`:
|
||||||
|
- **Cwd:** per-list → the list's repo working dir; global → the first selected task's repo (any valid repo; the agent works cross-repo via MCP).
|
||||||
|
- **`--add-dir`:** the session-brief dir **plus every distinct repo checkout** among the selected tasks (so the agent can read/resolve conflict markers in each repo). Computed from each task's list working dir.
|
||||||
|
- **Args:** `--permission-mode default`, `--allowedTools mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`, `--append-system-prompt-file <merge-helper-system.md>`, `--add-dir ...`, then the single-line kickoff prompt.
|
||||||
|
- `Edit` is required for conflict resolution; `Bash` is allowed for **read-only** git inspection (`git status`/`diff`) — the system prompt mandates that all merge *state changes* go through MCP tools, never raw `git merge/commit`, to keep the engine authoritative and honour the user's rejection of the "raw git" option.
|
||||||
|
- **Env:** `MCP_TOOL_TIMEOUT=200000` (as task/ad-hoc sessions set).
|
||||||
|
|
||||||
|
### 6.4 Hub + client + Mission Control
|
||||||
|
|
||||||
|
- **Hub:** `WorkerHub.GetMergeHelperLaunchSpec(string[] taskIds, string? listId)` → `_launchSpecService.BuildForMergeHelper(...)`. Sibling to `GetAdHocLaunchSpec` / `GetPlanningStartLaunchSpec`.
|
||||||
|
- **Client:** `IWorkerClient.GetMergeHelperLaunchSpecAsync(...)` + `WorkerClient` impl.
|
||||||
|
- **Mission Control:** `MissionControlViewModel.OpenMergeHelperConPtySessionAsync(spec)` — wraps the spec in a `TerminalLaunchDescriptor`, creates a `ConPtyPaneViewModel` (ad-hoc style, **never deduped** — each run is its own tile), adds it to `ConPtySessions`.
|
||||||
|
- **Event plumbing:** `ListsIslandViewModel` raises `LetClaudeHandleRequested(scope)`; `IslandsShellViewModel` forwards to Mission Control, which opens the selection dialog, then (on confirm) fetches the spec and opens the tile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Helper Behaviour (encoded in the system prompt)
|
||||||
|
|
||||||
|
For each selected task, act by status:
|
||||||
|
- **Idle / Queued:** `run_task_now`; poll `get_task` until terminal or `WaitingForReview`.
|
||||||
|
- **Failed:** `reset_failed_task` then run, *or* ask the user — failures often need a human call; default to asking briefly.
|
||||||
|
- **Running / WaitingForChildren:** poll `get_task` until it surfaces for review.
|
||||||
|
- **WaitingForReview:** `get_task_diff` (stat first, then full if needed), sanity-check the change against the task's intent, then `review_task approve` with `leaveConflictsInTree:true`.
|
||||||
|
- **Clean →** merged, task Done.
|
||||||
|
- **Conflict (`conflict_in_tree`) →** open the conflicted files under `repoPath` (Read/Edit), resolve the markers guided by both sides' intent, then `continue_merge`. If the resolution is non-obvious or risky, **ask the user in the terminal** before continuing. `abort_merge` if the user declines or it's unsafe.
|
||||||
|
- **Parent with children:** clean unit merge proceeds; on conflict, resolve via the MCP tools if they reach the orchestrated merge, else hand-merge the markers and complete it (§5.3) — asking the user first when the resolution is non-obvious.
|
||||||
|
|
||||||
|
Cross-cutting rules (in the prompt):
|
||||||
|
- Ask the user interactively for anything ambiguous, risky, or destructive — that is the point of the ConPTY session.
|
||||||
|
- Never use raw `git merge/commit/reset`; drive all merge state through the MCP tools.
|
||||||
|
- Keep a running tally; at the end print a **summary**: per task — final status, merge commit (if any), conflicts resolved, anything skipped, and suggested follow-ups.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Testing
|
||||||
|
|
||||||
|
**Automated (`ClaudeDo.Worker.Tests`, real SQLite + real git):**
|
||||||
|
- `review_task`/`merge_task` with `leaveConflictsInTree:true`: clean merge → Done; conflicting merge → `conflict_in_tree`, markers present in the checkout, task stays `WaitingForReview`.
|
||||||
|
- `continue_merge`: after markers resolved on disk → commits, task Done, worktree merged; with markers still present → returns remaining conflicts.
|
||||||
|
- `abort_merge`: in-progress merge aborted, markers gone, task stays `WaitingForReview`.
|
||||||
|
- `continue_merge`/`abort_merge` on a task with no in-progress merge → clean MCP error, no clobber.
|
||||||
|
- `TaskUpdated` fired after continue/abort.
|
||||||
|
- `BuildForMergeHelper`: computes distinct repo `--add-dir` set, correct cwd per scope, brief file rendered with all selected tasks, allowed-tools string correct.
|
||||||
|
|
||||||
|
**No real-Claude tests** (per project convention) — the end-to-end ConPTY run is a manual smoke item.
|
||||||
|
|
||||||
|
**Manual (add to `docs/open.md`):**
|
||||||
|
- ConPTY tile launches with the brief; MCP tools reachable; a clean multi-task run merges all and prints a summary.
|
||||||
|
- A seeded conflict is resolved autonomously via `continue_merge`.
|
||||||
|
- Interactive question round-trip (helper asks, user answers in terminal).
|
||||||
|
- Global (multi-repo) run with `--add-dir` for each repo.
|
||||||
|
- Selection dialog: grouping, default ticks, select-all/none, per-list vs global scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phasing
|
||||||
|
|
||||||
|
Delivered as one plan with three phases (see the plan doc). Phase A is independently useful and merges first.
|
||||||
|
|
||||||
|
- **Phase A — Worker MCP conflict tools** (§5): `leaveConflictsInTree` param + `continue_merge` + `abort_merge` + tests. No UI.
|
||||||
|
- **Phase B — Worker launch** (§6.1–6.4 worker side): prompt templates, `BuildForMergeHelper`, hub endpoint, session-file/brief generation, client method. Contract for C locked here.
|
||||||
|
- **Phase C — UI**: selection dialog (View+VM), per-list + global entries, event plumbing, `OpenMergeHelperConPtySessionAsync`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Out of scope (v1)
|
||||||
|
|
||||||
|
- Resuming a merge-helper session (`--resume`); sessions are ephemeral.
|
||||||
|
- A non-interactive/headless merge-helper (this is deliberately a ConPTY interactive session).
|
||||||
|
- Cross-list *batching* semantics beyond "act on each selected task independently."
|
||||||
|
- Any change to the existing per-task Approve/diff-gate flow.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# "Let Claude handle it" — per-list handler with read / dedupe / enhance / run / merge
|
||||||
|
|
||||||
|
Date: 2026-07-27
|
||||||
|
Supersedes parts of: `2026-07-24-merge-helper-design.md` (global scope, run-then-merge-only prompt)
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
The merge helper shipped in v2.3.0 with two entry points — a per-list context-menu item and a
|
||||||
|
global footer Broom button — and a prompt that only runs and merges the selected tasks.
|
||||||
|
|
||||||
|
Two things are wrong with that:
|
||||||
|
|
||||||
|
- **The global scope is unwanted.** A run spanning several lists spans several repos, which
|
||||||
|
makes `cwd`, the `--add-dir` set and the merge order ambiguous for no benefit. The user
|
||||||
|
works one list (= one repo) at a time.
|
||||||
|
- **The helper starts too late.** It takes the task list as given: it never reads the tasks as
|
||||||
|
a set, so duplicates run twice and produce conflicting worktrees, and vague tasks go into an
|
||||||
|
autonomous run under-specified and come back wrong.
|
||||||
|
|
||||||
|
## 2. Goal
|
||||||
|
|
||||||
|
One entry point, on a user list. It opens an interactive ConPTY session that takes the selected
|
||||||
|
tasks through five phases: read them all, dedupe them, sharpen them for autonomous execution,
|
||||||
|
run them, then review and merge each worktree.
|
||||||
|
|
||||||
|
Non-goals: no change to the autonomous queue path, no change to the ConPTY tile plumbing,
|
||||||
|
no rename of the `MergeHelper*` identifiers (the user-facing label stays "Let Claude handle it").
|
||||||
|
|
||||||
|
## 3. Scope becomes list-only
|
||||||
|
|
||||||
|
`listId` becomes non-nullable across the whole chain:
|
||||||
|
|
||||||
|
| Layer | Change |
|
||||||
|
|---|---|
|
||||||
|
| `ListsIslandViewModel` | `MergeHelperRequest(string ListId, …)`; `LetClaudeHandleAllAsync` deleted |
|
||||||
|
| `IslandsShellViewModel:241` | unchanged (already forwards `req.ListId`) |
|
||||||
|
| `MissionControlViewModel:327` | `OpenMergeHelperConPtySessionAsync(string listId, …)` |
|
||||||
|
| `IWorkerClient:90` / `WorkerClient:525` | `GetMergeHelperLaunchSpecAsync(taskIds, string listId, ct)` |
|
||||||
|
| `WorkerHub:682` | `GetMergeHelperLaunchSpec(string[] taskIds, string listId)` |
|
||||||
|
| `IInteractiveLaunchSpecService:36` | `BuildForMergeHelperAsync(taskIds, string listId, ct)` |
|
||||||
|
|
||||||
|
Deleted UI surface:
|
||||||
|
|
||||||
|
- Broom button `ListsIslandView.axaml:205-210` and `LetClaudeHandleAllCommand`.
|
||||||
|
- `IsGlobal` on `MergeHelperSelectionModalViewModel` and the LIST column
|
||||||
|
(`MergeHelperSelectionModal.axaml:71`) — with a single list the column is constant.
|
||||||
|
- Localization keys `lists.letClaudeAllTip`, `modals.mergeHelper.scopeAll`,
|
||||||
|
`modals.mergeHelper.columnList` (en + de, parity test enforces both).
|
||||||
|
|
||||||
|
`Configure(string listId, string listName)` loses its nullable overload; `ScopeLabel` always
|
||||||
|
renders `modals.mergeHelper.scopeList`.
|
||||||
|
|
||||||
|
### 3.1 Single repo in the launch spec
|
||||||
|
|
||||||
|
`BuildForMergeHelperAsync` currently collects a distinct `repoDirs` set across the selected
|
||||||
|
tasks and picks `cwd` per scope. With a list scope every task shares the list's `WorkingDir`,
|
||||||
|
so this collapses to:
|
||||||
|
|
||||||
|
- Load the list; throw `InvalidOperationException` if it has no existing `WorkingDir`.
|
||||||
|
- `cwd` = that directory; `--add-dir` = the session dir + that one directory.
|
||||||
|
- The per-task brief line drops the now-constant `list:` and `repo:` fields.
|
||||||
|
|
||||||
|
The context-menu item is already hidden when `WorkingDir` is empty, so the throw is a guard,
|
||||||
|
not a normal path.
|
||||||
|
|
||||||
|
### 3.2 Entry point visibility
|
||||||
|
|
||||||
|
The item stays in the list row's context menu, next to "List settings", "Worktrees overview",
|
||||||
|
"Open in Explorer" and "Open in Terminal". That is the established place for list-scoped
|
||||||
|
actions; a second always-visible button in the row would break the pattern.
|
||||||
|
|
||||||
|
## 4. Worker: `Cancelled` becomes externally settable
|
||||||
|
|
||||||
|
`ExternalMcpService.UpdateTaskStatus` accepts only `Idle` and `Queued` and throws
|
||||||
|
`"Status '{target}' is not settable externally. Use run_task_now or cancel_task."` for the
|
||||||
|
rest — but neither escape hatch reaches an **Idle** task: `cancel_task` only cancels a
|
||||||
|
*running* task, and `review_task(decision="cancel")` requires WaitingForReview/Running/Queued.
|
||||||
|
The dedupe phase needs exactly that: retire an Idle duplicate without destroying it.
|
||||||
|
|
||||||
|
Add to the switch:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
case TaskStatus.Cancelled:
|
||||||
|
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken);
|
||||||
|
if (!cancelResult.Ok)
|
||||||
|
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
`TaskStateService.CancelAsync` (`State/TaskStateService.cs:244`) already owns the transition
|
||||||
|
and its worktree/parent side effects. The existing error message in `UpdateTaskStatus` already
|
||||||
|
lists `Cancelled` as valid, so this also removes a lie. A cancelled task stays visible and can
|
||||||
|
be reset to Idle — nothing is lost, unlike `delete_task`.
|
||||||
|
|
||||||
|
## 5. The five-phase prompt
|
||||||
|
|
||||||
|
`PromptFiles.MergeHelperDefault` is rewritten. The session stays interactive and the helper is
|
||||||
|
told to ask whenever unsure — that is the point of a watched ConPTY session.
|
||||||
|
|
||||||
|
### Phase 0 — Read
|
||||||
|
|
||||||
|
`batch_get_tasks` over every id in the brief before touching anything: title, description,
|
||||||
|
status, parent/child links. The helper must hold the whole set in mind before acting on any
|
||||||
|
single task.
|
||||||
|
|
||||||
|
### Phase 1 — Dedupe
|
||||||
|
|
||||||
|
Compare the tasks pairwise for overlap. Emit a table of candidate pairs with the reason each
|
||||||
|
pair looks like a duplicate, then **ask per pair**:
|
||||||
|
|
||||||
|
- merge → fold the loser's unique content into the survivor via `update_task`, then
|
||||||
|
`update_task_status(loserId, "Cancelled")`;
|
||||||
|
- keep both → note why and move on.
|
||||||
|
|
||||||
|
Nothing is cancelled without an explicit answer.
|
||||||
|
|
||||||
|
### Phase 2 — Enhance
|
||||||
|
|
||||||
|
For each surviving task, sharpen title and description for autonomous execution:
|
||||||
|
|
||||||
|
- concrete acceptance criteria,
|
||||||
|
- the files/areas actually involved — grounded in the repo via Read/Grep/Glob, not guessed,
|
||||||
|
- explicit out-of-scope.
|
||||||
|
|
||||||
|
Write back with `update_task` (title / description / commitType are the settable fields; it
|
||||||
|
refuses while Running, which cannot happen this early). Rules: do not change intent, do not
|
||||||
|
invent requirements. A task too vague to sharpen safely gets a question, not a guess.
|
||||||
|
|
||||||
|
### Phase 3 — Run
|
||||||
|
|
||||||
|
`run_task_now` cannot be used for a batch: `OverrideSlotService.StartInSlot`
|
||||||
|
(`Queue/OverrideSlotService.cs:65-70`) holds a single slot and throws `"override slot busy"`
|
||||||
|
on the second concurrent call. The queue picker is the only parallel path.
|
||||||
|
|
||||||
|
So: read `get_app_settings`, tell the user how many parallel slots are configured
|
||||||
|
(`MaxParallelExecutions`, default 1 — `AppSettingsEntity.cs:14`), then
|
||||||
|
`update_task_status(id, "Queued")` for every surviving task, then poll `get_task` until each
|
||||||
|
has left Queued/Running — `WaitingForReview` on success, `Failed` on error. Announcing the
|
||||||
|
slot count up front stops the user wondering why "run them all" executes one at a time.
|
||||||
|
|
||||||
|
A task already `Running` or `WaitingForChildren` when the session starts is not re-queued, only
|
||||||
|
polled. A task already `WaitingForReview` skips straight to Phase 4.
|
||||||
|
|
||||||
|
### Phase 4 — Review and merge
|
||||||
|
|
||||||
|
Sequential, in list order. A task that came back `Failed` needs human judgement — ask whether
|
||||||
|
to `reset_failed_task` and re-queue it, or skip it. Otherwise, unchanged from the shipped
|
||||||
|
prompt: `get_task_diff` (stat first,
|
||||||
|
full diff when non-trivial), sanity-check against the task's intent, ask before merging
|
||||||
|
anything that looks wrong, then `review_task(taskId, decision="approve",
|
||||||
|
leaveConflictsInTree=true)` and the conflict loop (`continue_merge` / `abort_merge`, parent id
|
||||||
|
for unit merges, hand-resolution only where MCP cannot reach, always
|
||||||
|
`git commit -- <paths>` and never `git add -A` because the checkout is shared).
|
||||||
|
|
||||||
|
New in this phase: the prompt states that because Phase 3 branches all fork from the same
|
||||||
|
base, **conflicts are the normal case, not an exception** — resolve them rather than bailing
|
||||||
|
out of the run.
|
||||||
|
|
||||||
|
### Phase 5 — Summary
|
||||||
|
|
||||||
|
One line per task: title — dedupe action — enhanced? — final status — merge commit —
|
||||||
|
conflicts resolved. Then anything skipped and why, then follow-ups.
|
||||||
|
|
||||||
|
### Brief template
|
||||||
|
|
||||||
|
`MergeHelperInitialDefault` names the list and repo once in the header and drops the per-task
|
||||||
|
`list:`/`repo:` fields, leaving `- [{status}] {title} (id: {id})`. Descriptions stay out of the
|
||||||
|
brief; Phase 0 fetches them.
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
| Test | Change |
|
||||||
|
|---|---|
|
||||||
|
| `MergeHelperSelectionModalViewModelTests` | drop `IsGlobal`, list-scoped `Configure` |
|
||||||
|
| `InteractiveLaunchSpecServiceTests` | three `null`-listId cases → non-null; drop the two-repo global-scope test; add "list without WorkingDir throws" |
|
||||||
|
| `MissionControlViewModelTests` | four `OpenMergeHelperConPtySessionAsync(null, …)` calls |
|
||||||
|
| `StubWorkerClient`, `TasksIslandViewModelPlanningTests` fake | signature |
|
||||||
|
| `PromptFilesTests` | assert the five phase markers in the default prompt |
|
||||||
|
| `Localization.Tests` | parity after removing three keys |
|
||||||
|
| new: `ExternalMcpService` / worker test | `UpdateTaskStatus(id, "Cancelled")` cancels an Idle task; unknown status still throws |
|
||||||
|
|
||||||
|
No test spawns the real `claude` CLI — the prompt content is asserted as text, the session
|
||||||
|
itself is a manual smoke step.
|
||||||
|
|
||||||
|
## 7. Verification left to the user
|
||||||
|
|
||||||
|
- The list context menu shows "Let Claude handle it" only for lists with a working dir, and
|
||||||
|
the Broom button is gone from the footer row.
|
||||||
|
- The selection dialog has no LIST column and reads "List: <name>".
|
||||||
|
- A real ConPTY run: dedupe questions appear, enhancements land in the task descriptions,
|
||||||
|
queued tasks execute, merges complete or hand off to conflict resolution.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Verifikations-Handoff (Stand 2026-07-24)
|
||||||
|
|
||||||
|
Manuelle Verifikation am laufenden System. **Mika bedient die UI, die Session protokolliert Pass/Fail** und bereitet Fixtures vor (Tasks via ClaudeDo-MCP, Git-Setups im Testrepo). Aktive Findings landen in `docs/open.md`; hier steht der Fortschritt je Abschnitt.
|
||||||
|
|
||||||
|
## Handoff für die nächste Session
|
||||||
|
|
||||||
|
**Erledigt (2026-07-24):** §1 Detail-Insel/Diff-Viewer (bis auf DiffModal-Fehler-State), §2 Worktree-Pipeline (alle 3), §3 Planning-Walkthrough (inkl. UnfinishedPlanning-Modal: dismiss/Finalize/Discard PASS, **Resume BUG**), §4 Merge-Editor (Single-Task **und** Planning-Unit-Konflikt **und** Abort), §6 Pick-up-Gating (Code), §7 AskUser (Happy-Path + Timeout + UI-Cleanup; Finding: Banner nur in Mission Control), §8 Session Skills (Install/Aktivierung/Seeding/Gegenprobe/Remove; Findings: fehlender Empty-State + abweichendes Agent-Gear-Icon), §9 Attachments (Drag&Drop-UI + MCP + ComposedPreview; Finding: intermittenter erster-Drop-Fehler), §12 RunNow (moot).
|
||||||
|
|
||||||
|
**Noch offen:**
|
||||||
|
- **§10 Daily Prep/Weekly** — **Bewusst zurückgestellt (2026-07-24)**: Mika will Daily Prep + Weekly Report ohnehin überarbeiten — Verifikation lohnt erst nach dem Rework.
|
||||||
|
- **§11 Self-Update/Autostart** — **Nicht formell gefahren, aber laut Mika „läuft bisher sehr gut" (2026-07-24)** → als OK behandelt; bei Bedarf später gezielt nachtesten.
|
||||||
|
- ~~**Kanten:** §1 DiffModal-Fehler-State, §3 UnfinishedPlanning-Modal, §4 Abort~~ — **alle erledigt (2026-07-24)**: §4 Abort PASS, §3 Modal (Finalize/Discard PASS, Resume BUG), §1 via Code-Analyse geklärt (defensiv/unerreichbar).
|
||||||
|
|
||||||
|
**Vorbedingungen:** Worker + App laufen (SignalR 37821, External MCP 47822 — lt. `~/.todo-app/worker.config.json`). Testliste **`ClaudeDoTests`** (`C:\TestRepos\ClaudeDoTests`, listId `e7992fee6035448394b646d069690e05`) für zerstörungsfreie Runs.
|
||||||
|
|
||||||
|
**Wichtige Gotchas / Lessons (diese Session):**
|
||||||
|
- **NIE `model:"haiku"` für schreibende Task-/Verifikations-Runs** — unter dem Default `--permission-mode auto` werden haiku-Writes denied (still no-op). Immer **sonnet**. (Memory `auto_permission_haiku_footgun`.)
|
||||||
|
- **Merge-Preflight blockt bei dirty Ziel-Working-Tree** (getrackte uncommittete Änderung im `main`-Checkout) — und **Approve schluckt das still** (Bug, open.md). Vor Merge-Checks `git -C C:\TestRepos\ClaudeDoTests status --short` prüfen; fremde WIP nur mit Rücksprache verwerfen (Memory `shared_worktree_partial_commits`: nur pfad-scoped committen).
|
||||||
|
- **Konflikt-Fixture-Rezept:** Task (sonnet) überschreibt eine getrackte Datei im Branch → danach dieselbe Datei auf `main` divergent ändern + `git commit -- <pfad>` → Approve konfliktet. Für Planning-Unit-Konflikt: main-Edit erst scharfschalten, wenn der Parent in WaitingForReview steht.
|
||||||
|
- **Datenwahrheit** notfalls direkt aus der DB: `sqlite3 -readonly ~/.todo-app/todo.db "SELECT substr(id,1,8),status,planning_phase,substr(blocked_by_task_id,1,8),title FROM tasks WHERE …"`.
|
||||||
|
|
||||||
|
**Testrepo-/Fixture-Zustand:** Aufgeräumt (2026-07-24, §7+§8-Session-Ende) — alle `verif §7/§8`-Tasks gelöscht, deren Worktrees + Branches entfernt, `ClaudeDoTests`-`main` unangetastet bei `ecad650` (Tree clean; die §7-Runs liefen isoliert in Worktrees, main nie berührt). ponytail-Skills nach dem Remove-Test wieder deinstalliert (`session_skills` leer, `~/.todo-app/session-skills/` leer). Keine `verif`-Tasks und keine claudedotests-Worktrees übrig. Die übrigen `claudedo/*`-Branches im Testrepo stammen aus früheren Sessions — nicht anfassen. **Die nächste Session baut Fixtures frisch** (Rezepte s. Gotchas oben).
|
||||||
|
|
||||||
|
**Erfasste Folge-Tasks** (Liste „Claude do", Idle, brauchen Brainstorm vor Umsetzung): `f9809a93` Approve erzwingt Diff/Review vor Merge (+ blocked-Merge-Silent-Fail-Fix), `5d627df8` Planning-Session über embedded ConPTY statt wt (+ Planning-Permission-Prompt).
|
||||||
|
|
||||||
|
**Fix-Session:** Alle fixbaren Findings sind für eine frische Session in **`docs/fix-plan-2026-07-24.md`** aufbereitet (gruppiert nach Fixbarkeit: A mechanisch, B error-surfacing, C entscheidungsbedürftig, D investigation, E nits). Volltext je Finding bleibt in `docs/open.md`.
|
||||||
|
|
||||||
|
**Nacharbeit:** Erledigtes aus `docs/open.md` austragen; dieses File löschen, sobald §10 (nach Rework) + evtl. §11-Nachtest erledigt sind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Detail-Insel & Diff-Viewer (reines Durchklicken)
|
||||||
|
|
||||||
|
- [~] Detail-Insel komplett: Output/Git/Session-Tabs, Merge-Sektion, Agent-Settings-Overrides (InheritedBadge korrekt), Prep-Panel — nach dem VM-Split (`DetailsIslandViewModel` → Sektions-VMs) alles gebunden, keine leeren Panels. — **TEILWEISE (2026-07-24, User-Sichtprüfung an `verif §1 diff matrix`):** Output/Git-Tabs, Merge-Sektion, Agent-Badges vorhanden & gebunden, keine leeren Panels. Git-Tab: `+1 −37`, „merges cleanly" korrekt. **BUG:** OUTCOME-Karte zeigt rohes JSON (s. open.md). Session-Tab fehlt — **erwartet** (nur bei Parent mit Kindern, `HasChildOutcomes`; wird in §4/§3 mit echtem Parent geprüft). Minor: TurnsText `0/max` bei terminalem Reload (open.md). Prep-Panel → §10.
|
||||||
|
- [~] Diff-Viewer: Dateiliste, Added/Deleted/Renamed/Binary-Erkennung, Commit-Range-Diff nach einem Merge. — **Added/Deleted/Binary PASS**; **Renamed** erkannt + „R"-Badge, aber schwach dargestellt (kein alt→neu-Pfad, „+0 −0" — nit, s. open.md); „Review Combined Diff" korrekt ausgegraut (kein Planning-Parent). **Commit-Range-Diff nach Merge: PASS (2026-07-24)** — Diff des gemergten Done-Tasks rendert über `base..head` trotz entferntem Worktree.
|
||||||
|
- [x] DiffModal-Fehler-State: Commit-Range ohne aufgezeichnete Commits → „Diff nicht mehr verfügbar" statt Crash/leer. — **GEKLÄRT via Code-Analyse (2026-07-24): der Fehler-State ist defensiv/unerreichbar.** `vm.diff.unavailable` (DiffViewerViewModel.cs:117-119) feuert nur bei `FromCommitRange && (BaseRef==null || HeadCommit==null)` oder `WorktreePath==null`. Alle Aufrufer sind gegated: `MergeSectionViewModel.OpenDiffAsync` ruft `ConfigureCommitRange` nur unter `CanDiffMergedRange` (base **und** head non-null) und `ConfigureWorktree` nur unter `hasLiveWorktree` (Pfad non-null + existiert); `WorktreesOverviewModalViewModel` übergibt `row.Path` (non-null). Damit können die Null-Checks über die UI nie wahr werden — der „Crash/leer"-Fall, den der Check absichern wollte, ist durch die CanExecute-/Modus-Gates ausgeschlossen. (Fehlende Commit-Objekte bei vorhandenem base+head → `GetCommitRangeDiffAsync` wirft → „loadFailed", nicht „unavailable".) Nicht manuell auslösbar; kein Bug.
|
||||||
|
- [x] „children need attention"-Band auf dem Session-Tab eines Parents mit failed/blocked Kind. — **PASS (2026-07-24, in §3)**: Band + OUTCOMES-Liste mit Roadblock-Hinweis auf dem Session-Tab des Planning-Parents (`verif §3`, Roadblock-Kind).
|
||||||
|
|
||||||
|
## 2. Worktree-Pipeline (3 falsifizierbare Fälle)
|
||||||
|
|
||||||
|
- [x] Happy-Path: Task mit WorkingDir → `worktrees.state='active'`, `head_commit` gesetzt, `diff_stat` non-empty, Branch `claudedo/<id[:8]>` existiert auf Disk. — **PASS (2026-07-24)** via Task `e63eb2f5` (sonnet): Worktree active, `headCommit 8a3ae86` (ahead=1 vom Base), `diff_stat` non-empty (`review-cancel.txt`), Branch `claudedo/e63eb2f5…` auf Disk. (Mein erster Versuch mit `model=haiku` schlug fehl — das war ein haiku/auto-Footgun, keine Pipeline-Sache; s. open.md.)
|
||||||
|
- [x] No-Changes-Run: → `status='Done'`, `head_commit IS NULL`, `diff_stat IS NULL`. — **PASS (2026-07-24)**; Präzisierung: aktueller Flow endet in `WaitingForReview` (nicht `Done`, das ist erst nach Approve) — Doc-„Done" ist veraltet. Kein neuer Commit, leerer Diff bestätigt.
|
||||||
|
- [x] Kein Git-Repo (WorkingDir = `C:\Temp`): → `status='Failed'`, KEINE `worktrees`-Row, Git-Fehler im Log. — **PASS (2026-07-24)**; Fehler: „Worktree creation failed: working_dir is not a git repository".
|
||||||
|
|
||||||
|
## 3. Planning-Flow-Walkthrough
|
||||||
|
|
||||||
|
- [x] Draft → Finalize → Kette: Finalize queued NICHT automatisch (Kinder bleiben Idle); „Queue plan" setzt alle nicht-terminalen Kinder Queued, Kette läuft sequenziell durch (blocked-by löst sich je Vorgänger). — **PASS (2026-07-24, End-to-End mit `verif §3`)**: 3 Kinder gedraftet, Finalize → Parent WaitingForChildren+finalized, Kinder Idle+Kette (clean→conflict→roadblock), Queue plan → sequenzieller Durchlauf, alle Done. **Begleit-Findings (open.md):** „Waiting for Improvements"-Mislabel, Kind-Badge-Live-Refresh (Draft→Planned erst nach Listenwechsel), Kette nicht visualisiert, planning-aktiver Parent zeigt „Idle", Dequeue-X fehlt auf wartenden Kindern, Planning-Permission-Prompt, Planning nutzt wt statt ConPTY.
|
||||||
|
- [x] Parent landet nach letztem terminalen Kind in WaitingForReview; Approve merged die ganze Unit (Parent-Worktree falls Active + jedes Done-Kind in Reihenfolge). — **PASS (2026-07-24)**: Parent → WaitingForReview auch mit Roadblock-Kind; Approve → Unit-Merge (child-clean clean, child-conflict → Konflikt-Editor pro Subtask → aufgelöst → Continue, child-roadblock no-op) → Parent Done, Merge gelandet.
|
||||||
|
- [x] UnfinishedPlanning-Modal: Resume / FinalizeNow / Discard. — **GETESTET (2026-07-24, `verif §3 unfinished-planning` + `verif §3 resume-discard`)**: Modal wird via Rechtsklick→„Resume planning session" ausgelöst, zeigt Titel + „N draft task(s) waiting to be finalized" + Buttons Discard/Finalize/Resume + ×. **× (dismiss)**: No-op (State bleibt `active`). **Finalize**: Parent → `finalized`+`WaitingForChildren`, Kinder Idle+Blocked-by-Kette (= Planned) — PASS. **Discard**: Draft-Kinder gelöscht, Parent → `none`+`idle` — funktional PASS. **Resume: BUG — macht nichts** (`planning_session_id` nie erfasst → `ResumeAsync` wirft „No Claude session ID captured yet", vom UI im leeren `catch` verschluckt; s. open.md). Beide State-ändernden Aktionen reproduzieren das Kind-Rows-Live-Refresh-Finding (Finalize: Badges stale; Discard: gelöschte Rows bleiben bis Reload; open.md).
|
||||||
|
|
||||||
|
## 4. Merge-Editor (Rider-Style 3-Pane) mit echtem Konflikt
|
||||||
|
|
||||||
|
Konflikt provozieren: gleiche Datei auf main ändern, während der Task-Branch sie ändert. Beide Wege testen: **(a)** Single-Task-Approve mit Konflikt, **(b)** Planning-Unit-Merge mit Konflikt in einem Subtask (`PlanningMergeConflict` → Editor öffnet pro Subtask).
|
||||||
|
|
||||||
|
**Verifiziert 2026-07-24 (Single-Task-Approve mit Konflikt, 2 Dateien, `verif §4`): End-to-End PASS** — Approve→Konflikt→3-Pane-Resolver→beide Dateien auflösen→Continue→Merge→Task Done (Merge-Commit gelandet). Sauberer additiver Approve (`verif §1b`, kein Konflikt) → direkt gemergt → Done, kein Editor: **PASS (2026-07-24)**. ⚠ **Vorbedingung-Fund:** ein dirty Ziel-Working-Tree (getrackte uncommittete Änderung) blockt den Merge und Approve schluckt das **still** (s. open.md „Approve & Merge schluckt blocked"). Planning-Unit-Merge-Konflikt (Weg b): **PASS (2026-07-24, in §3)** — Approve eines Planning-Parents mit einem konfliktenden Subtask öffnet den 3-Pane-Editor pro Subtask (`PlanningMergeConflict`), Continue führt den Unit-Merge fort → Parent Done.
|
||||||
|
|
||||||
|
- [x] Drei Panes: MAIN read-only | Result editierbar | INCOMING read-only; Konfliktblöcke rot, aufgelöst grün, in allen Panes. — **PASS**.
|
||||||
|
- [x] Gutter-Toggle `›`/`‹`: Seite rein/raus, Klickreihenfolge = Reihenfolge im Result; main/incoming/beide/keine möglich. — **PASS**.
|
||||||
|
- [x] Nur Konfliktregionen im Result editierbar (Stable read-only); Edits fließen in den Block zurück. — **PASS**.
|
||||||
|
- [~] Synchrones vertikales Scrollen; File-Switcher bei mehreren Dateien; `M conflicts · K resolved`-Readout; Conflict-Ruler (Klick springt). — **PASS**, aber **Multi-File schlecht erkennbar** (2 Konfliktdateien schwer zu sehen — UX-Nit, open.md).
|
||||||
|
- [~] Continue erst aktiv, wenn ALLE Konflikte in ALLEN Dateien gelöst; Binär-Guard greift. — **funktional PASS** (merged erst nach Auflösung beider Dateien), aber **Button ist klickbar statt disabled** solange offen → stummer No-op (UX-Bug, open.md). Binär-Guard hier n/a.
|
||||||
|
- [x] Abort: Tree sauber, Task bleibt WaitingForReview. — **PASS (2026-07-24, `verif §4 abort`)**: Single-Task-Approve mit divergentem main→Konflikt→3-Pane-Editor→**Abort**: Editor schließt, Task bleibt `WaitingForReview`, `main`-Tree sauber (kein `MERGE_HEAD`, kein dirty), `main`-Inhalt + HEAD unverändert.
|
||||||
|
- [FEATURE-WUNSCH] farbliches Hervorheben der übernommenen/eingefügten Zeilen im Result-Pane (open.md).
|
||||||
|
- Bekannte Kanten (nur gegenprüfen, nicht als Fail werten): leere Ours-Seite → null-lange Result-Region (Accept geht, Handtippen fummelig); Gutter-Y-Ausrichtung bei sehr hohen Fenstern/großem Scroll; vertikaler Drift nachfolgender Blöcke nach Konflikt mit ungleicher Zeilenzahl.
|
||||||
|
|
||||||
|
## 5. Embedded ConPTY / Mission Control
|
||||||
|
|
||||||
|
- [x] **Kritisch:** Task-basiert (Kontextmenü „Open ConPTY session"): frischer Task → Worktree wird on-demand angelegt, `claude` startet — Task-Prompt wirklich GESENDET. — **PASS (2026-07-24)**: Agent antwortete mit dem Marker `CONPTY-PROMPT-RECEIVED-4Q7`, Worktree on-demand angelegt.
|
||||||
|
- [~] Ad-hoc: „New session"-Button → Ordnerwahl → freie Session im gewählten Verzeichnis. — **funktioniert, aber Button unsichtbar**: `Icon.Plus` ist Strich-Only → `PathIcon` rendert nichts (Bug, open.md). Über Tooltip/Klick auf die leere Header-Fläche erreichbar; Ordnerwahl + Ad-hoc-Session funktionieren.
|
||||||
|
- [x] Grid↔Tabs-Toggle; Close killt den Prozess + entfernt die Kachel; mehrere Sessions parallel; Pane-Resize reflowt das TUI. — **PASS (2026-07-24)**: Resize/Reflow ok; Close entfernt Kachel UND killt den Prozess (verifiziert: ConPTY-PID 50964, Kind von ClaudeDo.App, nach Close tot).
|
||||||
|
- [~] Resume: im Worktree-Dir per `claude --continue` möglich (ClaudeDo persistiert die ConPTY-Session-Id bewusst nicht). — **wie designt**: erneutes „Open ConPTY session" resumt NICHT, sondern startet frisch und **sendet den Prompt erneut** (UX-Falle, open.md). In-App-Resume gibt es nicht; nur claude-eigenes `--continue`/`--resume` im Worktree-Dir.
|
||||||
|
|
||||||
|
## 6. Pick up in terminal
|
||||||
|
|
||||||
|
- [x] Sichtbarkeit: Kontextmenü-Eintrag + Terminal-Button (ArrowOut) NUR bei WaitingForReview und Failed; bei Idle/Running/Queued/Done nicht. — **PASS (2026-07-24, Code+Test)**: `CanPickUpInTerminal => Status is WaitingForReview or Failed` (TaskRowViewModel.cs:69, DetailsIslandViewModel.cs:898), gebunden in TaskRowView.axaml:56 + TaskHeaderBar.axaml:34, Notify bei Statuswechsel, Unit-Test `CanPickUpInTerminal_OnlyForReviewOrFailed`. (Klick→Terminal + Fehler-Surfacing bleiben manueller UI/CLI-Check.)
|
||||||
|
- [ ] Klick → neues Windows-Terminal im Worktree-Verzeichnis, `claude --resume <id>` nimmt die Session mit Kontext wieder auf.
|
||||||
|
- [ ] Fehlerfälle surfacen sauber (Footer-Strip bzw. Fehlerdialog): laufende/gequeuete Task, keine persistierte Session-Id, kein aktiver Worktree.
|
||||||
|
- Bekannte Kante: parked-Idle (reject-park) hat oft Session+Worktree, zeigt die Aktion aber bewusst NICHT (Idle nicht unterscheidbar). Nervt das in der Praxis → `CanPickUpInTerminal` erweitern.
|
||||||
|
|
||||||
|
## 7. AskUser (Frage aus laufendem Task)
|
||||||
|
|
||||||
|
Runtime-Toolname ist `mcp__claudedo_run__ask_user` (snake_case, nicht `AskUser`). Fixture: sonnet-Task mit Prompt „zwei sich widersprechende, irreversible Optionen — du MUSST `ask_user` fragen, nicht raten" erzwingt den Call zuverlässig.
|
||||||
|
|
||||||
|
- [x] Task, dessen Prompt eine Rückfrage erzwingt → Frage erscheint im Task-Monitor (Mission Control), Prozess wartet. — **PASS (2026-07-24, `verif §7 askuser 2`)**: Banner in Mission Control, Run blockiert im `ask_user`-Tool-Call. **BUG:** in der Task-Detail-Insel erscheint KEIN Banner (nur Mission Control) → s. open.md.
|
||||||
|
- [x] Antwort inline absenden → Run läuft mit der Antwort weiter. — **PASS (2026-07-24)**: Antwort „A" inline gesendet → Agent bekam „option A", benannte `merge-playground.txt` → `renamed-by-agent.txt` um, schrieb `askuser-result.txt` = „USER CHOSE: A", Task → WaitingForReview.
|
||||||
|
- [x] Timeout-/Cleanup-Verhalten der PendingQuestionRegistry (Frage unbeantwortet lassen): Task schlägt kontrolliert fehl, UI räumt die Frage auf (MCP_TOOL_TIMEOUT-Gotcha). — **PASS (2026-07-24)**: Backend (`verif §7 askuser`) nach exakt 3 min Fallback zurück, Agent hielt sich an „MUST NOT guess", meldete `CLAUDEDO_BLOCKED`, Repo untouched, Registry im `finally` aufgeräumt, Run endete `success` → WaitingForReview. **UI-Cleanup visuell bestätigt** (`verif §7 timeout-ui`, MC-Kachel offen/subscribt bis zum Timeout): Banner verschwindet beim Timeout automatisch (`TaskQuestionResolved` → `ClearPendingQuestion`).
|
||||||
|
|
||||||
|
## 8. Session Skills (E2E + UI)
|
||||||
|
|
||||||
|
- [x] Settings → Skills: `https://github.com/DietrichGebert/ponytail` installieren → 6 Skills erscheinen (ponytail, -help, -review, -audit, -debt, -gain), auf Commit gepinnt, Dateien unter `~/.todo-app/session-skills/<name>/`. — **PASS (2026-07-24)**: 6 Rows in `session_skills`, alle auf Commit `16f29800` gepinnt, `subpath=skills/<name>`, Dateien inkl. `SKILL.md` am erwarteten Ort. UI-Install nahm die URL an. **Nit:** Skills-Tab hat keinen Empty-State (s. open.md).
|
||||||
|
- [x] Skill per-Task (Agent-Settings-Flyout) oder global aktivieren → Task laufen lassen → im Worktree liegt `.claude/skills/<name>/`, Agent kann ihn nutzen, `git status` im Worktree bleibt sauber (info/exclude greift). — **PASS (2026-07-24, per-Task, `verif §8 skill-activate`)**: `ponytail-help` per Flyout aktiviert (`task.SessionSkills=["ponytail-help"]`), Run → `.claude/skills/ponytail-help/SKILL.md` im Worktree, `info/exclude` bekam `/.claude/skills/ponytail-help/`, `git status` clean, Auto-Commit enthält NUR `skill-proof.txt` (Skill NICHT im Tree). Agent hat den Skill nachweislich genutzt (`skill-proof.txt` = ponytail-help-Referenzkarte verbatim). Global-Aktivierung (AppSettings.SessionSkills, General-Tab) nutzt denselben Seeding-Union-Pfad — funktional äquivalent, nicht separat gefahren.
|
||||||
|
- [x] Gegenprobe: nicht-aktivierte/andere interaktive Session sieht den Skill NICHT (kein Leak nach `~/.claude`). — **PASS (2026-07-24)**: `~/.claude/skills/` enthält nur die vorbestehenden globalen Skills, KEIN ponytail. Seeder schreibt per Konstruktion nur nach `<workingDir>/.claude/skills` (SessionSkillSeeder), nie nach `~/.claude`.
|
||||||
|
- [~] UI: Skills-Tab (Install-Zeile, Karten mit Update/Remove), Checkbox-Listen im General-Tab + AgentConfigEditor (Flyout-Höhe!), leerer Zustand (0 Skills — Empty-State fehlt evtl., dann entscheiden), lange Namen/URLs (Trimming). — **TEILWEISE (2026-07-24)**: Install-Zeile nimmt URL an (PASS); AgentConfigEditor-Flyout-Checkbox-Liste zeigt alle 6 Skills, Höhe ok, Trimming ok (User: „funktioniert wie erhofft"). **Empty-State fehlt** (nackte Fläche — open.md). **Agent-Settings-Gear ⚙ weicht vom Listen-Gear ab** (open.md). Skills-Tab-**Karten** (Name/Beschreibung/Pinned-Commit + Update/Remove) und **General-Tab-Checkbox-Liste** (6 Skills) sichtgeprüft (User: „sieht alles gut aus"). **Remove PASS (2026-07-24)**: ein Remove-Klick auf eine ponytail-Karte entfernte alle 6 Skills per Quell-URL (`session_skills` leer, alle Verzeichnisse unter `~/.todo-app/session-skills/` weg). **Update** nicht separat gefahren (Code-Pfad `SessionSkillRegistry.UpdateAsync`).
|
||||||
|
|
||||||
|
## 9. Attachments (Drag & Drop + MCP)
|
||||||
|
|
||||||
|
- [x] Drop aufs Detail-Pane: „Drop to attach"-Overlay, Datei erscheint in der Liste, landet unter `~/.todo-app/attachments/<taskId>/`; „Add file…"-Picker; Remove-Button. — **PASS (2026-07-24, `verif §9 attachments-ui`)**: Overlay/Highlight beim Drüberziehen, Drop-Round-Trip (`drop-me.txt` in Liste + Disk + DB, 29 B), „Add file…"-Picker (`pick-me.md`), Remove (Datei aus Liste + Disk + DB) — alle bestätigt. **Finding:** der ALLERERSTE Drop der Session schlug einmalig mit inline „An error occurred" fehl (nichts persistiert), danach fehlerfrei — intermittierend, nicht reproduzierbar (s. open.md).
|
||||||
|
- [x] `ComposedPreview` enthält die Attachment-Pfade („## Reference files"). — **PASS (2026-07-24, Code+Test)**: TaskPromptComposer.cs:31 emittiert „## Reference files", ComposedPreview reicht Pfade durch, TaskRunner.cs:132 injiziert zur Laufzeit; TaskPromptComposerTests decken es ab.
|
||||||
|
- [x] MCP: `add_task_attachment` / `list_task_attachments` / `remove_task_attachment`; Running-Task verweigert add/remove. — **PASS (2026-07-24)** für add/list/remove-Round-Trip inkl. Datei unter `~/.todo-app/attachments/<taskId>/` (71 B, korrekt, nach Remove weg). Running-Task-Verweigerung ist code-guarded (AttachmentMcpTools), aber ohne dauerhaft laufende Task nicht live geprüft → manueller Rest.
|
||||||
|
|
||||||
|
## 10. Daily Prep (Prime) & Weekly Report
|
||||||
|
|
||||||
|
- [ ] Prime-Trigger: Schedule feuert bzw. „Plan day" manuell → Prep-Log streamt live, `daily-prep.log` enthält letzten Run, MyDay-Auswahl respektiert `DailyPrepMaxTasks`.
|
||||||
|
- [ ] Weekly Report: Range-Default „seit letztem Standup-Wochentag → heute", Markdown rendert, Cache pro Range.
|
||||||
|
|
||||||
|
## 11. Self-Update & Autostart (am Gerät)
|
||||||
|
|
||||||
|
- [ ] Update-Banner → Update durchführen → danach „up to date".
|
||||||
|
- [ ] Autostart: Logoff/Logon startet den Worker (Startup-`.lnk`); Update-Pfad erhält den Autostart; Uninstall entfernt die `.lnk`.
|
||||||
|
|
||||||
|
## 12. Status-Bar / RunNow (Mini-Codecheck, erst messen)
|
||||||
|
|
||||||
|
- [x] Worker trennen/verbinden → prüfen, ob RunNow-Enable pro Task-Row sauber re-evaluiert (Connection-State lebt in `IslandsShellViewModel`). Nur fixen, wenn tatsächlich kaputt. — **PASS/moot (2026-07-24, Code)**: Es gibt kein per-Row-RunNow-Control in der UI; `RunNowAsync` (IWorkerClient/WorkerClient) hat keinen VM/View-Aufrufer (RunNow nur via MCP `run_task_now`). Die realen Detail-Pane-Aktionen (Enqueue/Dequeue/Continue/ResetAndRetry) re-evaluieren korrekt bei Connection-Change (DetailsIslandViewModel.cs:317-323). Nebenbefund: `RunNowAsync` in der UI ist toter Code.
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
<converters:DotBrushConverter x:Key="DotBrush"/>
|
<converters:DotBrushConverter x:Key="DotBrush"/>
|
||||||
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
|
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
|
||||||
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
|
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
|
||||||
|
<converters:LogKindForegroundConverter x:Key="LogKindForeground"/>
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
|
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
||||||
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
|
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
|
||||||
<!-- Global defaults: every Window inherits Inter Tight + body size.
|
<!-- Global defaults: every Window inherits Inter Tight + body size.
|
||||||
Controls that need mono opt in via their own class/style. -->
|
Controls that need mono opt in via their own class/style. -->
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
using System;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using ClaudeDo.Ui;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
using ClaudeDo.Ui.Views;
|
using ClaudeDo.Ui.Views;
|
||||||
@@ -10,25 +13,41 @@ namespace ClaudeDo.App;
|
|||||||
|
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
public static ServiceProvider Services { get; set; } = null!;
|
private readonly IServiceProvider? _services;
|
||||||
|
|
||||||
|
// Parameterless ctor is required by the XAML previewer / designer.
|
||||||
|
public App() { }
|
||||||
|
|
||||||
|
public App(IServiceProvider services) => _services = services;
|
||||||
|
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
if (_services?.GetService<AppSettings>() is { } settings)
|
||||||
|
AccentPresetService.Apply(AccentPresets.Find(settings.AccentPreset));
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
|
var services = _services
|
||||||
|
?? throw new InvalidOperationException("App was constructed without a service provider.");
|
||||||
|
|
||||||
|
FocusClearing.Install();
|
||||||
|
|
||||||
|
// The main window is authoritative — closing it shuts the app down even if the
|
||||||
|
// modeless Mission Control window is still open.
|
||||||
|
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||||
|
|
||||||
desktop.MainWindow = new MainWindow
|
desktop.MainWindow = new MainWindow
|
||||||
{
|
{
|
||||||
DataContext = Services.GetRequiredService<IslandsShellViewModel>(),
|
DataContext = services.GetRequiredService<IslandsShellViewModel>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Kick off the SignalR retry loop — reconnects indefinitely if the worker
|
// Kick off the SignalR retry loop — reconnects indefinitely if the worker
|
||||||
// is not up yet, or goes down and comes back.
|
// is not up yet, or goes down and comes back.
|
||||||
_ = Services.GetRequiredService<WorkerClient>().StartAsync();
|
_ = services.GetRequiredService<WorkerClient>().StartAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 55 KiB |
@@ -19,8 +19,8 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
|
|||||||
|
|
||||||
## DI Registration Pattern
|
## DI Registration Pattern
|
||||||
|
|
||||||
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
|
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`/`WorkerPrimeScheduleApi`, `INotesApi`/`WorkerNotesApi`, `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
|
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WeeklyReportModalViewModel`, `DiffViewerViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation (`Func<DiffViewerViewModel>` for the diff viewer); `ConflictResolverViewModel` via a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,12 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="12.0.0" />
|
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.0" />
|
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.0" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.0" />
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||||
|
<!-- Direct ref so the App.axaml AvaloniaEdit theme (avares://AvaloniaEdit/...) resolves at runtime. -->
|
||||||
|
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
|
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
|
||||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
@@ -28,5 +30,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ClaudeDo.Ui\ClaudeDo.Ui.csproj" />
|
<ProjectReference Include="..\ClaudeDo.Ui\ClaudeDo.Ui.csproj" />
|
||||||
|
<ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<Import Project="..\ClaudeDo.Localization\Locales.targets" />
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+57
-10
@@ -1,9 +1,12 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Localization;
|
||||||
using ClaudeDo.Releases;
|
using ClaudeDo.Releases;
|
||||||
using ClaudeDo.Ui;
|
using ClaudeDo.Ui;
|
||||||
|
using ClaudeDo.Ui.Localization;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.Services.Interfaces;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
@@ -11,6 +14,9 @@ using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
@@ -29,7 +35,6 @@ sealed class Program
|
|||||||
SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App");
|
SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App");
|
||||||
|
|
||||||
var services = BuildServices();
|
var services = BuildServices();
|
||||||
App.Services = services;
|
|
||||||
|
|
||||||
using (var scope = services.CreateScope())
|
using (var scope = services.CreateScope())
|
||||||
{
|
{
|
||||||
@@ -39,7 +44,7 @@ sealed class Program
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
BuildAvaloniaApp()
|
ConfigureAppBuilder(AppBuilder.Configure(() => new App(services)))
|
||||||
.StartWithClassicDesktopLifetime(args);
|
.StartWithClassicDesktopLifetime(args);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -52,8 +57,12 @@ sealed class Program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parameterless entry point required by the XAML previewer / designer.
|
||||||
public static AppBuilder BuildAvaloniaApp()
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
=> AppBuilder.Configure<App>()
|
=> ConfigureAppBuilder(AppBuilder.Configure<App>());
|
||||||
|
|
||||||
|
private static AppBuilder ConfigureAppBuilder(AppBuilder builder)
|
||||||
|
=> builder
|
||||||
.UsePlatformDetect()
|
.UsePlatformDetect()
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
.WithDeveloperTools()
|
.WithDeveloperTools()
|
||||||
@@ -70,6 +79,18 @@ sealed class Program
|
|||||||
|
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
sc.AddSingleton(settings);
|
sc.AddSingleton(settings);
|
||||||
|
var localesDir = Path.Combine(AppContext.BaseDirectory, "locales");
|
||||||
|
var localeStore = LocaleStore.Load(localesDir);
|
||||||
|
var initialLang = !string.IsNullOrWhiteSpace(settings.Language)
|
||||||
|
? settings.Language
|
||||||
|
: CultureResolver.Resolve(
|
||||||
|
CultureInfo.CurrentUICulture.Name,
|
||||||
|
localeStore.Available.Select(l => l.Code).ToArray(),
|
||||||
|
fallback: "en");
|
||||||
|
var localizer = new Localizer(localeStore, initialLang);
|
||||||
|
TrExtension.Localizer = localizer;
|
||||||
|
ClaudeDo.Ui.Localization.Loc.Current = localizer;
|
||||||
|
sc.AddSingleton<ILocalizer>(localizer);
|
||||||
sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
||||||
opt.UseSqlite($"Data Source={dbPath}"));
|
opt.UseSqlite($"Data Source={dbPath}"));
|
||||||
sc.AddScoped<ClaudeDoDbContext>(sp =>
|
sc.AddScoped<ClaudeDoDbContext>(sp =>
|
||||||
@@ -78,6 +99,7 @@ sealed class Program
|
|||||||
// Services
|
// Services
|
||||||
sc.AddSingleton<GitService>();
|
sc.AddSingleton<GitService>();
|
||||||
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
||||||
|
sc.AddSingleton<IWorkerClient>(sp => sp.GetRequiredService<WorkerClient>());
|
||||||
|
|
||||||
// Release check + installer update
|
// Release check + installer update
|
||||||
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
|
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
|
||||||
@@ -94,12 +116,19 @@ sealed class Program
|
|||||||
return new UpdateCheckService(releases, version);
|
return new UpdateCheckService(releases, version);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Conflict-merge coordinator: single seam the shell wires to its resolver entry.
|
||||||
|
sc.AddSingleton<MergeCoordinator>();
|
||||||
|
sc.AddSingleton<IMergeCoordinator>(sp => sp.GetRequiredService<MergeCoordinator>());
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
sc.AddTransient<WorktreeModalViewModel>();
|
sc.AddTransient<DiffViewerViewModel>();
|
||||||
sc.AddTransient<Func<WorktreeModalViewModel>>(sp => () => sp.GetRequiredService<WorktreeModalViewModel>());
|
sc.AddTransient<Func<DiffViewerViewModel>>(sp => () => sp.GetRequiredService<DiffViewerViewModel>());
|
||||||
sc.AddTransient<WorktreesOverviewModalViewModel>();
|
sc.AddTransient<WorktreesOverviewModalViewModel>();
|
||||||
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
||||||
|
sc.AddTransient<MergeHelperSelectionModalViewModel>();
|
||||||
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
||||||
|
sc.AddSingleton<INotesApi, WorkerNotesApi>();
|
||||||
|
sc.AddSingleton<IOnlineLoginService, OnlineLoginService>();
|
||||||
sc.AddTransient<PrimeClaudeTabViewModel>();
|
sc.AddTransient<PrimeClaudeTabViewModel>();
|
||||||
sc.AddTransient<SettingsModalViewModel>();
|
sc.AddTransient<SettingsModalViewModel>();
|
||||||
sc.AddTransient<MergeModalViewModel>();
|
sc.AddTransient<MergeModalViewModel>();
|
||||||
@@ -107,23 +136,41 @@ sealed class Program
|
|||||||
sc.AddTransient<ListSettingsModalViewModel>();
|
sc.AddTransient<ListSettingsModalViewModel>();
|
||||||
sc.AddTransient<RepoImportModalViewModel>();
|
sc.AddTransient<RepoImportModalViewModel>();
|
||||||
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
|
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
|
||||||
|
sc.AddTransient<WeeklyReportModalViewModel>();
|
||||||
|
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
|
||||||
|
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
|
||||||
|
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
|
||||||
|
sp.GetRequiredService<IWorkerClient>(), taskId));
|
||||||
|
|
||||||
// Islands shell VMs
|
// Islands shell VMs
|
||||||
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||||
new ListsIslandViewModel(
|
new ListsIslandViewModel(
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
sp,
|
sp,
|
||||||
sp.GetRequiredService<WorkerClient>()));
|
sp.GetRequiredService<IWorkerClient>()));
|
||||||
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
||||||
new TasksIslandViewModel(
|
new TasksIslandViewModel(
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
sp.GetRequiredService<WorkerClient>()));
|
sp.GetRequiredService<IWorkerClient>()));
|
||||||
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
||||||
new DetailsIslandViewModel(
|
new DetailsIslandViewModel(
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
sp.GetRequiredService<WorkerClient>(),
|
sp.GetRequiredService<IWorkerClient>(),
|
||||||
sp));
|
sp,
|
||||||
sc.AddSingleton<IslandsShellViewModel>();
|
sp.GetRequiredService<INotesApi>(),
|
||||||
|
sp.GetRequiredService<IMergeCoordinator>()));
|
||||||
|
sc.AddSingleton<MissionControlViewModel>(sp =>
|
||||||
|
new MissionControlViewModel(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
|
sp.GetRequiredService<IWorkerClient>()));
|
||||||
|
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
||||||
|
{
|
||||||
|
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
||||||
|
shell.ConflictResolverFactory =
|
||||||
|
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
|
||||||
|
sp.GetRequiredService<MergeCoordinator>().Handler = shell.RequestConflictResolutionAsync;
|
||||||
|
return shell;
|
||||||
|
});
|
||||||
|
|
||||||
return sc.BuildServiceProvider();
|
return sc.BuildServiceProvider();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
namespace ClaudeDo.Data;
|
||||||
|
|
||||||
|
public sealed class AttachmentStore
|
||||||
|
{
|
||||||
|
private const long MaxBytes = 5 * 1024 * 1024; // 5 MB
|
||||||
|
|
||||||
|
private readonly string _root;
|
||||||
|
|
||||||
|
public AttachmentStore(string? root = null)
|
||||||
|
=> _root = root ?? Paths.Expand("~/.todo-app/attachments");
|
||||||
|
|
||||||
|
public string Root => _root;
|
||||||
|
|
||||||
|
public IReadOnlyList<string> EnumerateTaskIds()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_root)) return Array.Empty<string>();
|
||||||
|
return Directory.GetDirectories(_root)
|
||||||
|
.Select(Path.GetFileName)
|
||||||
|
.Where(n => n is not null)
|
||||||
|
.Select(n => n!)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TaskDir(string taskId)
|
||||||
|
=> Path.Combine(_root, taskId);
|
||||||
|
|
||||||
|
public async Task<long> SaveAsync(string taskId, string fileName, Stream content, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (Path.GetFileName(fileName) != fileName)
|
||||||
|
throw new ArgumentException("fileName must not contain path separators or '..'.", nameof(fileName));
|
||||||
|
|
||||||
|
var dir = TaskDir(taskId);
|
||||||
|
var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName));
|
||||||
|
|
||||||
|
// Containment guard: resolved path must stay inside TaskDir
|
||||||
|
var resolvedDir = Path.GetFullPath(dir);
|
||||||
|
if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal)
|
||||||
|
&& !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal))
|
||||||
|
throw new ArgumentException("fileName resolves outside the task directory.", nameof(fileName));
|
||||||
|
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
// Buffer up to MaxBytes + 1 to detect oversize without reading fully
|
||||||
|
await using var fs = new FileStream(resolvedPath, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||||
|
bufferSize: 81920, useAsync: true);
|
||||||
|
|
||||||
|
var buffer = new byte[81920];
|
||||||
|
long total = 0;
|
||||||
|
int read;
|
||||||
|
while ((read = await content.ReadAsync(buffer, ct)) > 0)
|
||||||
|
{
|
||||||
|
total += read;
|
||||||
|
if (total > MaxBytes)
|
||||||
|
{
|
||||||
|
fs.Close();
|
||||||
|
try { File.Delete(resolvedPath); } catch { }
|
||||||
|
throw new InvalidOperationException($"Attachment exceeds the 5 MB size limit.");
|
||||||
|
}
|
||||||
|
await fs.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeleteFile(string taskId, string fileName)
|
||||||
|
{
|
||||||
|
if (Path.GetFileName(fileName) != fileName)
|
||||||
|
return; // traversal attempt — ignore silently
|
||||||
|
|
||||||
|
var dir = TaskDir(taskId);
|
||||||
|
var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName));
|
||||||
|
var resolvedDir = Path.GetFullPath(dir);
|
||||||
|
if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal)
|
||||||
|
&& !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal))
|
||||||
|
return; // containment violation — ignore silently
|
||||||
|
|
||||||
|
try { File.Delete(resolvedPath); } catch (DirectoryNotFoundException) { } catch (FileNotFoundException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeleteTaskDir(string taskId)
|
||||||
|
{
|
||||||
|
var dir = TaskDir(taskId);
|
||||||
|
try { Directory.Delete(dir, recursive: true); } catch (DirectoryNotFoundException) { } catch (IOException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,21 +4,31 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
|||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
- **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.
|
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual (reminder only the user can do — automation skips it), 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
|
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`)
|
||||||
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath (all nullable)
|
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable)
|
||||||
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
|
- **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)
|
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path)
|
||||||
|
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range.
|
||||||
|
- **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes`
|
||||||
|
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → table `week_reports`, unique index on (start_date, end_date)
|
||||||
|
- **TaskAttachmentEntity** — Id, TaskId (FK to tasks, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → table `task_attachments`
|
||||||
|
- **AppSettingsEntity** also carries `ReportExcludedPaths` (string?, JSON array of excluded path prefixes, column `report_excluded_paths`), `StandupWeekday` (int DayOfWeek, default Wednesday, column `standup_weekday`), and `DailyPrepMaxTasks` (int, default 5, column `daily_prep_max_tasks` — hard cap on how many open tasks the daily-prep / "Prime Claude" feature may place in MyDay) and `ModelPresets` (string?, JSON array of `ModelPreset` rows, column `model_presets`)
|
||||||
|
- **ModelPresets** / **ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`): one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1–200) and never throw — a malformed settings row must not stop a run. `For(presets, model)` always returns a usable row. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25.
|
||||||
|
- **EffortRegistry** — the `--effort` levels (`low|medium|high|xhigh|max`) + `NormalizeLevel` (blank → null = don't pass the flag)
|
||||||
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
|
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
|
||||||
|
|
||||||
## Repositories
|
## Repositories
|
||||||
|
|
||||||
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here.
|
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, 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.
|
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `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`
|
- **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
|
||||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
||||||
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||||
|
- **DailyNoteRepository** — `ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync`
|
||||||
|
- **WeekReportRepository** — `GetByRangeAsync`, `UpsertAsync`
|
||||||
|
- **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`, `DeleteAllForTaskAsync`
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
@@ -26,14 +36,15 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
|
|||||||
- **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service)
|
- **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service)
|
||||||
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
|
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
|
||||||
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
|
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
|
||||||
|
- **AttachmentStore** — dependency-free file store; default root `~/.todo-app/attachments/<taskId>/`. `SaveAsync` enforces a 5 MB cap and path-traversal/containment guard. Also exposes `DeleteFile`, `DeleteTaskDir`, `TaskDir`, `Root`, and `EnumerateTaskIds` (used by the worker orphan sweep). Attachment files live outside git worktrees intentionally.
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo
|
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Worktree ops (add — serialized to avoid a commondir race —, remove, prune, list paths for branch), branch ops (current, list local, checkout, delete), staging/commit (status porcelain, add-all, add-path, commit via stdin), diffs (working tree, branch vs base, commit range `base..head` — used to show a merged task's diff after the worktree is gone —, per-file, diff-stat, committed files, has-changes), merge (ff-only, no-ff, abort, mid-merge detection, conflicted files), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
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`).
|
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. 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`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using System.Data.Common;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Seeding;
|
using ClaudeDo.Data.Seeding;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
@@ -9,16 +11,47 @@ namespace ClaudeDo.Data;
|
|||||||
|
|
||||||
public class ClaudeDoDbContext : DbContext
|
public class ClaudeDoDbContext : DbContext
|
||||||
{
|
{
|
||||||
|
// Runs PRAGMA foreign_keys=ON on every EF-managed connection open so FK
|
||||||
|
// enforcement is active for all IDbContextFactory-created contexts, not
|
||||||
|
// just the single context used in MigrateAndConfigure.
|
||||||
|
private sealed class SqliteForeignKeyInterceptor : DbConnectionInterceptor
|
||||||
|
{
|
||||||
|
internal static readonly SqliteForeignKeyInterceptor Instance = new();
|
||||||
|
|
||||||
|
public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
|
||||||
|
=> Apply(connection);
|
||||||
|
|
||||||
|
public override Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Apply(connection);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Apply(DbConnection connection)
|
||||||
|
{
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "PRAGMA foreign_keys=ON;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
|
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
=> optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance);
|
||||||
|
|
||||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
||||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||||
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
|
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
|
||||||
|
public DbSet<TaskAttachmentEntity> TaskAttachments => Set<TaskAttachmentEntity>();
|
||||||
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
|
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
|
||||||
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
|
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
|
||||||
|
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();
|
||||||
|
public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>();
|
||||||
|
public DbSet<SessionSkillEntity> SessionSkills => Set<SessionSkillEntity>();
|
||||||
|
|
||||||
private static readonly ValueConverter<DateTime, DateTime> UtcConverter =
|
private static readonly ValueConverter<DateTime, DateTime> UtcConverter =
|
||||||
new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
|
new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
|||||||
builder.Property(s => s.RepoImportFolders)
|
builder.Property(s => s.RepoImportFolders)
|
||||||
.HasColumnName("repo_import_folders");
|
.HasColumnName("repo_import_folders");
|
||||||
|
|
||||||
|
builder.Property(s => s.ReportExcludedPaths).HasColumnName("report_excluded_paths");
|
||||||
|
builder.Property(s => s.StandupWeekday).HasColumnName("standup_weekday")
|
||||||
|
.IsRequired().HasDefaultValue((int)DayOfWeek.Wednesday);
|
||||||
|
|
||||||
|
builder.Property(s => s.DailyPrepMaxTasks)
|
||||||
|
.HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5);
|
||||||
|
|
||||||
|
builder.Property(s => s.SessionSkills).HasColumnName("session_skills");
|
||||||
|
builder.Property(s => s.ModelPresets).HasColumnName("model_presets");
|
||||||
|
|
||||||
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Configuration;
|
||||||
|
|
||||||
|
public class DailyNoteEntityConfiguration : IEntityTypeConfiguration<DailyNoteEntity>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<DailyNoteEntity> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("daily_notes");
|
||||||
|
builder.HasKey(n => n.Id);
|
||||||
|
builder.Property(n => n.Id).HasColumnName("id").ValueGeneratedNever();
|
||||||
|
builder.Property(n => n.Date).HasColumnName("note_date").IsRequired();
|
||||||
|
builder.Property(n => n.Text).HasColumnName("text").IsRequired();
|
||||||
|
builder.Property(n => n.SortOrder).HasColumnName("sort_order").IsRequired();
|
||||||
|
builder.Property(n => n.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||||
|
builder.HasIndex(n => n.Date);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,5 +15,7 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
|
|||||||
builder.Property(c => c.Model).HasColumnName("model");
|
builder.Property(c => c.Model).HasColumnName("model");
|
||||||
builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt");
|
builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt");
|
||||||
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
|
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
|
||||||
|
builder.Property(c => c.MaxTurns).HasColumnName("max_turns");
|
||||||
|
builder.Property(c => c.SessionSkills).HasColumnName("session_skills");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
|
|||||||
builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
|
builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
|
||||||
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
|
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
|
||||||
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||||
|
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
|
||||||
|
|
||||||
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
|
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ public class PrimeScheduleEntityConfiguration : IEntityTypeConfiguration<PrimeSc
|
|||||||
builder.HasKey(s => s.Id);
|
builder.HasKey(s => s.Id);
|
||||||
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
|
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
|
||||||
|
|
||||||
builder.Property(s => s.StartDate).HasColumnName("start_date").IsRequired();
|
builder.Property(s => s.Days).HasColumnName("days_of_week")
|
||||||
builder.Property(s => s.EndDate).HasColumnName("end_date").IsRequired();
|
.IsRequired().HasDefaultValue(PrimeDays.Weekdays);
|
||||||
builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").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.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true);
|
||||||
builder.Property(s => s.LastRunAt).HasColumnName("last_run_at");
|
builder.Property(s => s.LastRunAt).HasColumnName("last_run_at");
|
||||||
builder.Property(s => s.PromptOverride).HasColumnName("prompt_override");
|
builder.Property(s => s.PromptOverride).HasColumnName("prompt_override");
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Configuration;
|
||||||
|
|
||||||
|
public class SessionSkillEntityConfiguration : IEntityTypeConfiguration<SessionSkillEntity>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<SessionSkillEntity> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("session_skills");
|
||||||
|
|
||||||
|
builder.HasKey(s => s.Name);
|
||||||
|
builder.Property(s => s.Name).HasColumnName("name");
|
||||||
|
builder.Property(s => s.SourceUrl).HasColumnName("source_url").IsRequired();
|
||||||
|
builder.Property(s => s.PinnedRef).HasColumnName("pinned_ref").IsRequired();
|
||||||
|
builder.Property(s => s.Subpath).HasColumnName("subpath").IsRequired();
|
||||||
|
builder.Property(s => s.Description).HasColumnName("description").IsRequired();
|
||||||
|
builder.Property(s => s.AddedAt).HasColumnName("added_at").IsRequired();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Configuration;
|
||||||
|
|
||||||
|
public class TaskAttachmentEntityConfiguration : IEntityTypeConfiguration<TaskAttachmentEntity>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TaskAttachmentEntity> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("task_attachments");
|
||||||
|
|
||||||
|
builder.HasKey(a => a.Id);
|
||||||
|
builder.Property(a => a.Id).HasColumnName("id");
|
||||||
|
builder.Property(a => a.TaskId).HasColumnName("task_id").IsRequired();
|
||||||
|
builder.Property(a => a.FileName).HasColumnName("file_name").IsRequired();
|
||||||
|
builder.Property(a => a.ByteSize).HasColumnName("byte_size").IsRequired();
|
||||||
|
builder.Property(a => a.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(a => a.Task)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(a => a.TaskId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasIndex(a => a.TaskId).HasDatabaseName("idx_task_attachments_task_id");
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user