Compare commits
332
Commits
c5a4e350e9
..
v2.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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:
|
||||
- 'v*'
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -38,11 +42,52 @@ jobs:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
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" \
|
||||
"$WORK/src"
|
||||
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)
|
||||
env:
|
||||
WORK: ${{ steps.ws.outputs.dir }}
|
||||
@@ -100,18 +145,19 @@ jobs:
|
||||
ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
|
||||
( 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)
|
||||
if [ -z "$INSTALLER_EXE" ]; then
|
||||
echo "::error::No .exe produced by installer publish" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe"
|
||||
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer.exe"
|
||||
|
||||
# 3) Checksums (sha256, relative filenames)
|
||||
( cd assets && sha256sum \
|
||||
"ClaudeDo-${VERSION}-win-x64.zip" \
|
||||
"ClaudeDo.Installer-${VERSION}.exe" \
|
||||
"ClaudeDo.Installer.exe" \
|
||||
> checksums.txt )
|
||||
|
||||
echo "--- assets ---"
|
||||
@@ -128,7 +174,8 @@ jobs:
|
||||
BODY=$(jq -n \
|
||||
--arg tag "$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 \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -154,7 +201,7 @@ jobs:
|
||||
cd "$WORK/src/assets"
|
||||
for f in \
|
||||
"ClaudeDo-${VERSION}-win-x64.zip" \
|
||||
"ClaudeDo.Installer-${VERSION}.exe" \
|
||||
"ClaudeDo.Installer.exe" \
|
||||
"checksums.txt"
|
||||
do
|
||||
echo "Uploading: $f"
|
||||
@@ -166,6 +213,32 @@ jobs:
|
||||
done
|
||||
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
|
||||
if: always()
|
||||
env:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Local dev worktrees (created by using-git-worktrees skill)
|
||||
.worktrees/
|
||||
.claude/worktrees/
|
||||
|
||||
# Brainstorming visual companion artifacts
|
||||
.superpowers/
|
||||
|
||||
# .NET build output
|
||||
bin/
|
||||
|
||||
+1182
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.Data** — SQLite data layer, repositories, models, GitService
|
||||
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
|
||||
- **ClaudeDo.Worker.Tests** — xUnit integration tests with real SQLite and real git
|
||||
- **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
|
||||
|
||||
@@ -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)
|
||||
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
|
||||
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
|
||||
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A standalone task's successful run lands in WaitingForReview (planning children go straight to Done); from review you can approve (Done), reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel (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
|
||||
- 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)
|
||||
@@ -75,6 +79,9 @@ dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||
|
||||
## Docs
|
||||
|
||||
- `docs/plan.md` — full architecture and design spec
|
||||
- `docs/open.md` — verification checklist and improvement backlog
|
||||
- `docs/improvement-plan.md` — prioritized improvement items
|
||||
- `docs/open.md` — open verification items and remaining code TODOs (the only doc kept current besides the CLAUDE.md files)
|
||||
- `docs/plan.md` — original design spec (historical; tag-queue/schema.sql parts are outdated)
|
||||
- `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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
+34
-15
@@ -1,30 +1,49 @@
|
||||
# ClaudeDo — Offene Punkte
|
||||
|
||||
Stand: 2026-06-04. **Nur noch offene Punkte.** Was erledigt ist, steht in den Commits und im Code — nicht hier.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Manuelle Verifikation (offen)
|
||||
## Bugs (verifiziert 2026-07-24)
|
||||
|
||||
Kein Code-Aufwand, nur Durchspielen mit explizit notiertem Pass-Kriterium. Der Großteil der Pipeline ist laut User bereits in der Praxis getestet; hier das, was noch ein falsifizierbares Observable braucht.
|
||||
- **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.
|
||||
|
||||
- **Worktree-Pipeline:**
|
||||
- Worktree-Happy-Path → `worktrees.state='active'`, `head_commit` gesetzt, `diff_stat` non-empty, Branch `claudedo/<id>` auf Disk.
|
||||
- No-Changes-Run → `status='Done'`, `head_commit IS NULL`, `diff_stat IS NULL`.
|
||||
- Kein Git-Repo (`working_dir=C:\Temp`) → `status='Failed'`, **keine** `worktrees`-Row, Git-Fehler im Log.
|
||||
- **Feature-Walkthroughs:** Planning-Session-Flow (Draft→Finalize→Chain), Prime/Daily-Prep-Trigger, Weekly-Report-Generierung, Self-Update (Banner → Update → „up to date").
|
||||
- **Worker-Autostart am Gerät:** Logoff/Logon-Autostart, Update-Pfad, Uninstall entfernt die Startup-`.lnk`.
|
||||
## UX / Nits (verifiziert 2026-07-24)
|
||||
|
||||
## Offene Code-Punkte
|
||||
- **„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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
- **Status-Bar Live-Update:** Prüfen, ob `RunNow`-Enable/Disable pro Task-Row bei Connection-Change sauber re-evaluiert. Connection-Status lebt in `IslandsShellViewModel` / `WorkerConnectionModalViewModel` (es gibt keinen `StatusBarViewModel` mehr). Erst messen, dann ggf. fixen. Klein.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
## 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`.
|
||||
|
||||
---
|
||||
|
||||
## Bewusst verworfen (nicht erneut vorschlagen)
|
||||
|
||||
- **CI-Build/Test-Pipeline** — push-to-main + release-on-push deckt das ab; Tests laufen am Ende jeder Session.
|
||||
- **Real-`claude`-Smoke-Test als xUnit-Test** — kein Claude in `dotnet test`; bleibt manueller Check (siehe oben). Tests nutzen `FakeClaudeProcess`.
|
||||
- **`architecture.md` / ADRs** — die per-Projekt-`CLAUDE.md`-Dateien sind die lebende Doku; ADRs lohnen solo nicht.
|
||||
- **Task-Mailbox-Integration** — geparkt; das generische `mcp__mailbox__*`-Plugin reicht (Begründung in `mailbox-proposal.md`).
|
||||
- **Tag-Negation, Tag-Multi-Select, Notes-`lists.kind`-Switch, Install-Service-Skript** — durch die aktuelle Architektur überholt (Tag-System entfernt, Notes/Autostart anders gelöst).
|
||||
- **Real-`claude`-Smoke-Test als xUnit-Test** — kein Claude in `dotnet test`; bleibt manueller Check. Tests nutzen `FakeClaudeProcess`.
|
||||
- **`architecture.md` / ADRs** — die per-Projekt-`CLAUDE.md`-Dateien sind die lebende Doku.
|
||||
- **Task-Mailbox-Integration** — geparkt; das generische `mcp__mailbox__*`-Plugin reicht (`mailbox-proposal.md`).
|
||||
- **Tag-Negation, Tag-Multi-Select, Notes-`lists.kind`-Switch, Install-Service-Skript** — durch die aktuelle Architektur überholt.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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,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,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,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,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,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:BoolToItalicConverter x:Key="BoolToItalic"/>
|
||||
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
|
||||
<converters:LogKindForegroundConverter x:Key="LogKindForeground"/>
|
||||
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
@@ -31,6 +32,7 @@
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
||||
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
|
||||
<!-- Global defaults: every Window inherits Inter Tight + body size.
|
||||
Controls that need mono opt in via their own class/style. -->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using ClaudeDo.Ui.Services;
|
||||
@@ -32,6 +33,10 @@ public partial class App : Application
|
||||
|
||||
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
|
||||
{
|
||||
DataContext = services.GetRequiredService<IslandsShellViewModel>(),
|
||||
|
||||
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
|
||||
|
||||
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
|
||||
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation
|
||||
- **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`, `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
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||
<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">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
|
||||
@@ -116,13 +116,19 @@ sealed class Program
|
||||
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
|
||||
sc.AddTransient<WorktreeModalViewModel>();
|
||||
sc.AddTransient<Func<WorktreeModalViewModel>>(sp => () => sp.GetRequiredService<WorktreeModalViewModel>());
|
||||
sc.AddTransient<DiffViewerViewModel>();
|
||||
sc.AddTransient<Func<DiffViewerViewModel>>(sp => () => sp.GetRequiredService<DiffViewerViewModel>());
|
||||
sc.AddTransient<WorktreesOverviewModalViewModel>();
|
||||
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
||||
sc.AddTransient<MergeHelperSelectionModalViewModel>();
|
||||
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
||||
sc.AddSingleton<INotesApi, WorkerNotesApi>();
|
||||
sc.AddSingleton<IOnlineLoginService, OnlineLoginService>();
|
||||
sc.AddTransient<PrimeClaudeTabViewModel>();
|
||||
sc.AddTransient<SettingsModalViewModel>();
|
||||
sc.AddTransient<MergeModalViewModel>();
|
||||
@@ -132,24 +138,39 @@ sealed class Program
|
||||
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
|
||||
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||
new ListsIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp,
|
||||
sp.GetRequiredService<WorkerClient>()));
|
||||
sp.GetRequiredService<IWorkerClient>()));
|
||||
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
||||
new TasksIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<WorkerClient>()));
|
||||
sp.GetRequiredService<IWorkerClient>()));
|
||||
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
||||
new DetailsIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<WorkerClient>(),
|
||||
sp.GetRequiredService<IWorkerClient>(),
|
||||
sp,
|
||||
sp.GetRequiredService<INotesApi>()));
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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,7 +4,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
|
||||
## Models
|
||||
|
||||
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|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, 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, 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
|
||||
- **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)
|
||||
@@ -12,6 +12,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
- **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)
|
||||
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
|
||||
|
||||
@@ -19,12 +20,13 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
|
||||
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`
|
||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
||||
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||
- **DailyNoteRepository** — `ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync`
|
||||
- **WeekReportRepository** — `GetByRangeAsync`, `UpsertAsync`
|
||||
- **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`, `DeleteAllForTaskAsync`
|
||||
|
||||
## Infrastructure
|
||||
|
||||
@@ -32,14 +34,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)
|
||||
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
|
||||
- **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
|
||||
|
||||
- **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
|
||||
|
||||
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`. 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).
|
||||
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. `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
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Data.Common;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Seeding;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
@@ -9,18 +11,47 @@ namespace ClaudeDo.Data;
|
||||
|
||||
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) { }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
=> optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance);
|
||||
|
||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
|
||||
public DbSet<TaskAttachmentEntity> TaskAttachments => Set<TaskAttachmentEntity>();
|
||||
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
|
||||
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 =
|
||||
new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
|
||||
|
||||
@@ -44,6 +44,8 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
||||
builder.Property(s => s.DailyPrepMaxTasks)
|
||||
.HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5);
|
||||
|
||||
builder.Property(s => s.SessionSkills).HasColumnName("session_skills");
|
||||
|
||||
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
|
||||
builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt");
|
||||
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
|
||||
builder.Property(c => c.MaxTurns).HasColumnName("max_turns");
|
||||
builder.Property(c => c.SessionSkills).HasColumnName("session_skills");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
|
||||
builder.Property(t => t.Notes).HasColumnName("notes");
|
||||
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||
builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
|
||||
|
||||
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
|
||||
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
|
||||
|
||||
@@ -7,8 +7,7 @@ public sealed class ReviewFilter : ITaskListFilter
|
||||
{
|
||||
public string Id => "virtual:review";
|
||||
public bool Matches(TaskEntity t) =>
|
||||
t.Status == TaskStatus.Done &&
|
||||
t.Worktree is { State: WorktreeState.Active };
|
||||
t.Status == TaskStatus.WaitingForReview;
|
||||
public bool ShouldCount(TaskEntity t) => Matches(t);
|
||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace ClaudeDo.Data.Git;
|
||||
|
||||
/// <summary>
|
||||
/// One piece of a conflicted file: either common ("stable") text both sides agree on,
|
||||
/// or a conflict region holding the two — or, with diff3 markers, three — competing versions.
|
||||
/// </summary>
|
||||
public sealed record MergeSegment
|
||||
{
|
||||
public bool IsConflict { get; init; }
|
||||
|
||||
/// <summary>Stable text (verbatim, line endings preserved) when <see cref="IsConflict"/> is false.</summary>
|
||||
public string Text { get; init; } = "";
|
||||
|
||||
/// <summary>"Ours" side (the target branch) when <see cref="IsConflict"/> is true.</summary>
|
||||
public string Ours { get; init; } = "";
|
||||
|
||||
/// <summary>Merge base, present only when the merge used diff3 conflict style; null otherwise.</summary>
|
||||
public string? Base { get; init; }
|
||||
|
||||
/// <summary>"Theirs" side (the incoming branch) when <see cref="IsConflict"/> is true.</summary>
|
||||
public string Theirs { get; init; } = "";
|
||||
|
||||
public static MergeSegment Stable(string text) => new() { Text = text };
|
||||
|
||||
public static MergeSegment Conflict(string ours, string? @base, string theirs) =>
|
||||
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a conflicted file's text into ordered stable / conflict segments and reassembles it.
|
||||
/// Reads git conflict markers verbatim, so a file with no markers yields a single stable
|
||||
/// segment, and reassembling the stable text plus one chosen resolution per conflict
|
||||
/// round-trips the file exactly (line endings included).
|
||||
/// </summary>
|
||||
public static class ConflictMarkerParser
|
||||
{
|
||||
private const string OursMarker = "<<<<<<<";
|
||||
private const string BaseMarker = "|||||||";
|
||||
private const string SepMarker = "=======";
|
||||
private const string TheirsMarker = ">>>>>>>";
|
||||
|
||||
public static IReadOnlyList<MergeSegment> Parse(string fileText)
|
||||
{
|
||||
var segments = new List<MergeSegment>();
|
||||
var lines = SplitKeepLineEndings(fileText);
|
||||
var stable = new StringBuilder();
|
||||
var i = 0;
|
||||
|
||||
while (i < lines.Count)
|
||||
{
|
||||
if (!IsMarker(lines[i], OursMarker))
|
||||
{
|
||||
stable.Append(lines[i++]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stable.Length > 0)
|
||||
{
|
||||
segments.Add(MergeSegment.Stable(stable.ToString()));
|
||||
stable.Clear();
|
||||
}
|
||||
|
||||
i++; // consume "<<<<<<<"
|
||||
var ours = new StringBuilder();
|
||||
while (i < lines.Count && !IsMarker(lines[i], BaseMarker) && !IsMarker(lines[i], SepMarker))
|
||||
ours.Append(lines[i++]);
|
||||
|
||||
string? @base = null;
|
||||
if (i < lines.Count && IsMarker(lines[i], BaseMarker))
|
||||
{
|
||||
i++; // consume "|||||||"
|
||||
var baseText = new StringBuilder();
|
||||
while (i < lines.Count && !IsMarker(lines[i], SepMarker))
|
||||
baseText.Append(lines[i++]);
|
||||
@base = baseText.ToString();
|
||||
}
|
||||
|
||||
if (i < lines.Count && IsMarker(lines[i], SepMarker)) i++; // consume "======="
|
||||
|
||||
var theirs = new StringBuilder();
|
||||
while (i < lines.Count && !IsMarker(lines[i], TheirsMarker))
|
||||
theirs.Append(lines[i++]);
|
||||
|
||||
if (i < lines.Count && IsMarker(lines[i], TheirsMarker)) i++; // consume ">>>>>>>"
|
||||
|
||||
segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString()));
|
||||
}
|
||||
|
||||
if (stable.Length > 0)
|
||||
segments.Add(MergeSegment.Stable(stable.ToString()));
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/// <summary>True when the file still contains an opening conflict marker.</summary>
|
||||
public static bool HasConflicts(string fileText) =>
|
||||
SplitKeepLineEndings(fileText).Any(l => IsMarker(l, OursMarker));
|
||||
|
||||
/// <summary>
|
||||
/// Reassembles a file from its segments. Stable segments emit their text verbatim;
|
||||
/// each conflict segment emits whatever <paramref name="resolveConflict"/> returns for it.
|
||||
/// </summary>
|
||||
public static string Compose(
|
||||
IEnumerable<MergeSegment> segments, Func<MergeSegment, string> resolveConflict) =>
|
||||
string.Concat(segments.Select(s => s.IsConflict ? resolveConflict(s) : s.Text));
|
||||
|
||||
// A marker line starts with exactly the 7-char marker, then end-of-line or whitespace/label.
|
||||
private static bool IsMarker(string line, string marker)
|
||||
{
|
||||
if (!line.StartsWith(marker, StringComparison.Ordinal)) return false;
|
||||
if (line.Length == marker.Length) return true;
|
||||
return line[marker.Length] is ' ' or '\t' or '\r' or '\n';
|
||||
}
|
||||
|
||||
// Splits into physical lines, each retaining its trailing "\n" (and "\r" if present).
|
||||
private static List<string> SplitKeepLineEndings(string s)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var i = 0;
|
||||
while (i < s.Length)
|
||||
{
|
||||
var nl = s.IndexOf('\n', i);
|
||||
if (nl < 0) { lines.Add(s[i..]); break; }
|
||||
lines.Add(s[i..(nl + 1)]);
|
||||
i = nl + 1;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,15 @@ using System.Text;
|
||||
|
||||
namespace ClaudeDo.Data.Git;
|
||||
|
||||
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
|
||||
|
||||
public sealed class GitService
|
||||
{
|
||||
// git mutates shared .git/worktrees/ metadata during `worktree add`; concurrent adds
|
||||
// race and fail with "failed to read .git/worktrees/<other>/commondir". Serialize them
|
||||
// process-wide so parallel task starts don't collide.
|
||||
private static readonly SemaphoreSlim WorktreeAddGate = new(1, 1);
|
||||
|
||||
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct);
|
||||
@@ -21,10 +28,30 @@ public sealed class GitService
|
||||
|
||||
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
|
||||
await WorktreeAddGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
const int maxAttempts = 3;
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
|
||||
if (exitCode == 0)
|
||||
return;
|
||||
|
||||
// Transient races leave a half-written worktree metadata dir; retry briefly.
|
||||
var transient = stderr.Contains("commondir", StringComparison.OrdinalIgnoreCase)
|
||||
|| stderr.Contains("failed to read", StringComparison.OrdinalIgnoreCase);
|
||||
if (!transient || attempt >= maxAttempts)
|
||||
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
|
||||
|
||||
await Task.Delay(150 * attempt, ct);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
WorktreeAddGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
|
||||
@@ -44,9 +71,22 @@ public sealed class GitService
|
||||
return stdout;
|
||||
}
|
||||
|
||||
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
|
||||
public Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default) =>
|
||||
HasChangesAsync(worktreePath, includeUntracked: true, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Uncommitted-changes check. <paramref name="includeUntracked"/>=false ignores untracked
|
||||
/// files — use this for merge preflights on a shared target working dir, where stray
|
||||
/// untracked files (e.g. from a concurrent session) shouldn't block a merge. Auto-commit
|
||||
/// and data-loss-guard callers keep the default (true): a new file a task created, or an
|
||||
/// untracked file about to be discarded, is a real uncommitted change.
|
||||
/// </summary>
|
||||
public async Task<bool> HasChangesAsync(string worktreePath, bool includeUntracked, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
|
||||
string[] args = includeUntracked
|
||||
? ["status", "--porcelain"]
|
||||
: ["status", "--porcelain", "--untracked-files=no"];
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
|
||||
return !string.IsNullOrWhiteSpace(stdout);
|
||||
@@ -97,6 +137,20 @@ public sealed class GitService
|
||||
return await GetDiffAsync(worktreePath, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff between two commits, run in any repo that can reach them. Used to view a
|
||||
/// task's changes after its worktree has been merged away (the commits survive on
|
||||
/// the target branch even though the worktree directory and branch ref are gone).
|
||||
/// </summary>
|
||||
public async Task<string> GetCommitRangeDiffAsync(string repoDir, string baseCommit, string headCommit, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||
["diff", $"{baseCommit}..{headCommit}"], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git diff {baseCommit}..{headCommit} failed (exit {exitCode}): {stderr}");
|
||||
return stdout;
|
||||
}
|
||||
|
||||
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
||||
@@ -211,8 +265,11 @@ public sealed class GitService
|
||||
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
|
||||
string repoDir, string sourceBranch, string message, CancellationToken ct = default)
|
||||
{
|
||||
// diff3 conflict style writes the merge base (|||||||) into conflict markers so the
|
||||
// in-app resolver can show a true three-way view. It only enriches conflicted hunks;
|
||||
// clean merges are unaffected.
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["merge", "--no-ff", "-m", message, sourceBranch], ct);
|
||||
["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch], ct);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
@@ -236,15 +293,58 @@ public sealed class GitService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
|
||||
public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"Fast-forward merge of '{branchName}' failed. Manual merge required. git stderr: {stderr}");
|
||||
throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}");
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
|
||||
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null)
|
||||
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
@@ -293,6 +393,6 @@ public sealed class GitService
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
|
||||
return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260609000000_UniqueListName")]
|
||||
partial class UniqueListName
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
StandupWeekday = 3,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.ToTable("daily_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("week_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UniqueListName : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Remove duplicate list rows that have no tasks — keep the oldest rowid.
|
||||
// This handles the startup-race case where both App and Worker seeded
|
||||
// the same default list names concurrently.
|
||||
migrationBuilder.Sql("""
|
||||
DELETE FROM lists
|
||||
WHERE (SELECT COUNT(*) FROM tasks WHERE list_id = lists.id) = 0
|
||||
AND rowid NOT IN (
|
||||
SELECT MIN(l2.rowid) FROM lists l2 WHERE l2.name = lists.name
|
||||
)
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260622150934_AddTaskAttachments")]
|
||||
partial class AddTaskAttachments
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
StandupWeekday = 3,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.ToTable("daily_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_attachments_task_id");
|
||||
|
||||
b.ToTable("task_attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("week_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTaskAttachments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "task_attachments",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
task_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
file_name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
byte_size = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_task_attachments", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_task_attachments_tasks_task_id",
|
||||
column: x => x.task_id,
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_task_attachments_task_id",
|
||||
table: "task_attachments",
|
||||
column: "task_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "task_attachments");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260703072917_AddSessionSkills")]
|
||||
partial class AddSessionSkills
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
StandupWeekday = 3,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.ToTable("daily_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTimeOffset>("AddedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("added_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("PinnedRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pinned_ref");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("source_url");
|
||||
|
||||
b.Property<string>("Subpath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subpath");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("session_skills", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_attachments_task_id");
|
||||
|
||||
b.ToTable("task_attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("week_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSessionSkills : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "session_skills",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "session_skills",
|
||||
table: "list_config",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "session_skills",
|
||||
table: "app_settings",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "session_skills",
|
||||
columns: table => new
|
||||
{
|
||||
name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
source_url = table.Column<string>(type: "TEXT", nullable: false),
|
||||
pinned_ref = table.Column<string>(type: "TEXT", nullable: false),
|
||||
subpath = table.Column<string>(type: "TEXT", nullable: false),
|
||||
description = table.Column<string>(type: "TEXT", nullable: false),
|
||||
added_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_session_skills", x => x.name);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
column: "session_skills",
|
||||
value: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "session_skills");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "session_skills",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "session_skills",
|
||||
table: "list_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "session_skills",
|
||||
table: "app_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
@@ -168,6 +172,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
@@ -256,6 +264,41 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTimeOffset>("AddedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("added_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("PinnedRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pinned_ref");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("source_url");
|
||||
|
||||
b.Property<string>("Subpath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subpath");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("session_skills", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -294,6 +337,38 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_attachments_task_id");
|
||||
|
||||
b.ToTable("task_attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -405,6 +480,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
@@ -625,6 +704,17 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
|
||||
@@ -27,4 +27,7 @@ public sealed class AppSettingsEntity
|
||||
|
||||
// Max number of open tasks the daily prep ("Prime Claude") may place in MyDay.
|
||||
public int DailyPrepMaxTasks { get; set; } = 5;
|
||||
|
||||
// JSON array of session skill names applied by default to new tasks.
|
||||
public string? SessionSkills { get; set; }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ public sealed class ListConfigEntity
|
||||
public string? SystemPrompt { get; set; }
|
||||
public string? AgentPath { get; set; }
|
||||
public int? MaxTurns { get; set; }
|
||||
public string? SessionSkills { get; set; }
|
||||
|
||||
// Navigation property
|
||||
public ListEntity List { get; set; } = null!;
|
||||
|
||||
@@ -4,9 +4,26 @@ public static class ModelRegistry
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku" };
|
||||
|
||||
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
|
||||
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" };
|
||||
|
||||
public const string DefaultAlias = "sonnet";
|
||||
public const string PlanningAlias = "opus";
|
||||
|
||||
public const string ListDefaultSentinel = "(default)";
|
||||
public const string TaskInheritSentinel = "(inherit)";
|
||||
|
||||
/// <summary>
|
||||
/// Validate a model alias from external input. Null/blank → null (inherit).
|
||||
/// Returns the canonical lowercase alias; throws on an unknown value.
|
||||
/// </summary>
|
||||
public static string? NormalizeAlias(string? model)
|
||||
{
|
||||
var m = model?.Trim();
|
||||
if (string.IsNullOrEmpty(m)) return null;
|
||||
foreach (var alias in Aliases)
|
||||
if (string.Equals(alias, m, StringComparison.OrdinalIgnoreCase))
|
||||
return alias;
|
||||
throw new ArgumentException($"Unknown model '{model}'. Allowed: {string.Join(", ", Aliases)}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public sealed class SessionSkillEntity
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string SourceUrl { get; set; }
|
||||
public required string PinnedRef { get; set; }
|
||||
public required string Subpath { get; set; }
|
||||
public required string Description { get; set; }
|
||||
public required DateTimeOffset AddedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public sealed class TaskAttachmentEntity
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string TaskId { get; init; }
|
||||
public required string FileName { get; set; }
|
||||
public long ByteSize { get; set; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
|
||||
// Navigation property
|
||||
public TaskEntity Task { get; set; } = null!;
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public sealed class TaskEntity
|
||||
public bool IsMyDay { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public string? SessionSkills { get; set; }
|
||||
|
||||
public string? ParentTaskId { get; set; }
|
||||
public string? PlanningSessionId { get; set; }
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Text;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild }
|
||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial }
|
||||
|
||||
public static class PromptFiles
|
||||
{
|
||||
@@ -17,6 +17,9 @@ public static class PromptFiles
|
||||
PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"),
|
||||
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
|
||||
PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"),
|
||||
PromptKind.Refine => Path.Combine(Root, "refine.md"),
|
||||
PromptKind.MergeHelper => Path.Combine(Root, "merge-helper-system.md"),
|
||||
PromptKind.MergeHelperInitial => Path.Combine(Root, "merge-helper-initial.md"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||
};
|
||||
|
||||
@@ -61,6 +64,9 @@ public static class PromptFiles
|
||||
PromptKind.DailyPrep => DailyPrepDefault,
|
||||
PromptKind.WeeklyReport => WeeklyReportDefault,
|
||||
PromptKind.ImprovementChild => ImprovementChildDefault,
|
||||
PromptKind.Refine => RefineDefault,
|
||||
PromptKind.MergeHelper => MergeHelperDefault,
|
||||
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
|
||||
_ => ""
|
||||
};
|
||||
|
||||
@@ -80,7 +86,10 @@ public static class PromptFiles
|
||||
## 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.
|
||||
SuggestImprovement(title, description, model) and stay focused on the task at hand.
|
||||
Set `model` to the cheapest model that can do the follow-up well — 'haiku' for
|
||||
trivial/mechanical work, 'sonnet' for normal coding, 'opus' only for genuinely
|
||||
complex work (cheapest to most capable: haiku < sonnet < opus).
|
||||
|
||||
## Working in the repo
|
||||
- Read a file before editing it. Match the conventions already in this codebase —
|
||||
@@ -100,9 +109,12 @@ public static class PromptFiles
|
||||
- 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.
|
||||
You run autonomously, usually with no one watching. Default to making the most
|
||||
reasonable decision yourself, noting the assumption, and continuing — do not stop
|
||||
for routine choices. The one exception: at a genuine fork where a wrong guess
|
||||
would be costly or hard to undo (an irreversible action, contradictory
|
||||
requirements), you may call AskUser(question) to ask the user and wait briefly for
|
||||
an answer. If no one responds in time, proceed on your best judgment.
|
||||
|
||||
## When you are blocked
|
||||
If something genuinely prevents you from completing part of the task (missing
|
||||
@@ -120,8 +132,8 @@ public static class PromptFiles
|
||||
# Out-of-scope follow-up
|
||||
|
||||
You are an improvement follow-up that another task filed via SuggestImprovement.
|
||||
It was deliberately scoped narrow. Do EXACTLY what this task's title and
|
||||
description ask — nothing more.
|
||||
It was deliberately scoped narrow, and is intentionally a small, cheap unit of
|
||||
work. Do EXACTLY what this task's title and description ask — nothing more.
|
||||
|
||||
- Make the smallest change that satisfies the task. No opportunistic refactors,
|
||||
renames, reformatting, or "while I'm here" cleanup beyond what is asked.
|
||||
@@ -148,6 +160,14 @@ public static class PromptFiles
|
||||
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.
|
||||
|
||||
For each subtask, pass CreateChildTask's `model` argument set to the CHEAPEST
|
||||
model that can do that subtask well. Models, cheapest to most capable:
|
||||
haiku < sonnet < opus.
|
||||
- haiku — trivial/mechanical work: doc tweaks, simple renames, small localized edits.
|
||||
- sonnet — normal coding work; the sensible default when unsure.
|
||||
- opus — only for genuinely complex, cross-cutting, or hard-to-debug work.
|
||||
Do not default everything to opus — most subtasks are haiku or sonnet.
|
||||
""";
|
||||
|
||||
private const string PlanningInitialDefault = """
|
||||
@@ -181,6 +201,79 @@ public static class PromptFiles
|
||||
If there are no candidates, do nothing.
|
||||
""";
|
||||
|
||||
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.
|
||||
""";
|
||||
|
||||
private const string MergeHelperDefault = """
|
||||
You are the ClaudeDo Merge Helper, 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 each one to a merged, Done state, then print a summary of everything that changed. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it lists each task's id, title, status, list and repo.
|
||||
|
||||
Handle the tasks in the order listed, one at a time. For each task, act on its current status:
|
||||
- Idle or Queued: run it with run_task_now, then poll get_task until it leaves Running (it lands in WaitingForReview on success, or Failed).
|
||||
- Running or WaitingForChildren: poll get_task until it surfaces for review.
|
||||
- Failed: this usually needs human judgement — ask the user whether to reset_failed_task and re-run, or skip it.
|
||||
- WaitingForReview: review, then merge it (below).
|
||||
|
||||
Reviewing and merging a WaitingForReview task:
|
||||
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/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 it 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 (below).
|
||||
|
||||
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:
|
||||
- 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.
|
||||
|
||||
When every task is handled, print a SUMMARY:
|
||||
- one line per task: title — final status — merge commit (if any) — conflicts resolved (if any)
|
||||
- anything you skipped or left for the user, and why
|
||||
- suggested follow-ups, if any.
|
||||
""";
|
||||
|
||||
private const string MergeHelperInitialDefault = """
|
||||
# Merge Helper brief
|
||||
|
||||
Scope: {scope}
|
||||
|
||||
Handle the following tasks, in order. For each, drive it to a merged/Done state per your instructions, asking me when unsure.
|
||||
|
||||
{tasks}
|
||||
|
||||
When every task is handled, print the summary.
|
||||
""";
|
||||
|
||||
private const string WeeklyReportDefault = """
|
||||
You are generating a concise weekly standup report for a software developer,
|
||||
covering {start} to {end}.
|
||||
|
||||
@@ -18,8 +18,18 @@ public sealed class AppSettingsRepository
|
||||
|
||||
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
||||
_context.AppSettings.Add(row);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
_context.Entry(row).State = EntityState.Detached;
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(ct);
|
||||
_context.Entry(row).State = EntityState.Detached;
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Concurrent process already inserted the singleton — discard our attempt and re-read.
|
||||
_context.Entry(row).State = EntityState.Detached;
|
||||
row = await _context.AppSettings.AsNoTracking()
|
||||
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -54,6 +64,7 @@ public sealed class AppSettingsRepository
|
||||
? null : updated.ReportExcludedPaths;
|
||||
row.StandupWeekday = updated.StandupWeekday;
|
||||
row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks;
|
||||
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,13 @@ namespace ClaudeDo.Data.Repositories;
|
||||
public sealed class ListRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
private readonly AttachmentStore _attachments;
|
||||
|
||||
public ListRepository(ClaudeDoDbContext context) => _context = context;
|
||||
public ListRepository(ClaudeDoDbContext context, AttachmentStore? attachments = null)
|
||||
{
|
||||
_context = context;
|
||||
_attachments = attachments ?? new AttachmentStore();
|
||||
}
|
||||
|
||||
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
@@ -23,7 +28,13 @@ public sealed class ListRepository
|
||||
|
||||
public async Task DeleteAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
var taskIds = await _context.Tasks
|
||||
.Where(t => t.ListId == listId)
|
||||
.Select(t => t.Id)
|
||||
.ToListAsync(ct);
|
||||
await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct);
|
||||
foreach (var id in taskIds)
|
||||
_attachments.DeleteTaskDir(id);
|
||||
}
|
||||
|
||||
public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)
|
||||
@@ -66,6 +77,7 @@ public sealed class ListRepository
|
||||
existing.SystemPrompt = config.SystemPrompt;
|
||||
existing.AgentPath = config.AgentPath;
|
||||
existing.MaxTurns = config.MaxTurns;
|
||||
existing.SessionSkills = config.SessionSkills;
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class SessionSkillRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public SessionSkillRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _context.SessionSkills.AsNoTracking()
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<SessionSkillEntity?> GetAsync(string name, CancellationToken ct = default) =>
|
||||
await _context.SessionSkills.AsNoTracking().FirstOrDefaultAsync(s => s.Name == name, ct);
|
||||
|
||||
public async Task UpsertAsync(SessionSkillEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await _context.SessionSkills.FirstOrDefaultAsync(s => s.Name == entity.Name, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
_context.SessionSkills.Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.SourceUrl = entity.SourceUrl;
|
||||
existing.PinnedRef = entity.PinnedRef;
|
||||
existing.Subpath = entity.Subpath;
|
||||
existing.Description = entity.Description;
|
||||
existing.AddedAt = entity.AddedAt;
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
await _context.SessionSkills
|
||||
.Where(s => s.Name == name)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteBySourceAsync(string sourceUrl, CancellationToken ct = default)
|
||||
{
|
||||
await _context.SessionSkills
|
||||
.Where(s => s.SourceUrl == sourceUrl)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SessionSkillEntity>> ListBySourceAsync(string sourceUrl, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.SessionSkills.AsNoTracking()
|
||||
.Where(s => s.SourceUrl == sourceUrl)
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class TaskAttachmentRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public TaskAttachmentRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task AddAsync(TaskAttachmentEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
_context.TaskAttachments.Add(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<TaskAttachmentEntity>> ListByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.TaskAttachments
|
||||
.Where(a => a.TaskId == taskId)
|
||||
.OrderBy(a => a.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<TaskAttachmentEntity?> GetAsync(string taskId, string fileName, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.TaskAttachments
|
||||
.FirstOrDefaultAsync(a => a.TaskId == taskId && a.FileName == fileName, ct);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(TaskAttachmentEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
_context.TaskAttachments.Update(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string taskId, string fileName, CancellationToken ct = default)
|
||||
{
|
||||
await _context.TaskAttachments
|
||||
.Where(a => a.TaskId == taskId && a.FileName == fileName)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAllForTaskAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await _context.TaskAttachments
|
||||
.Where(a => a.TaskId == taskId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,13 @@ namespace ClaudeDo.Data.Repositories;
|
||||
public sealed class TaskRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
private readonly AttachmentStore _attachments;
|
||||
|
||||
public TaskRepository(ClaudeDoDbContext context) => _context = context;
|
||||
public TaskRepository(ClaudeDoDbContext context, AttachmentStore? attachments = null)
|
||||
{
|
||||
_context = context;
|
||||
_attachments = attachments ?? new AttachmentStore();
|
||||
}
|
||||
|
||||
#region CRUD
|
||||
|
||||
@@ -37,6 +42,7 @@ public sealed class TaskRepository
|
||||
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct);
|
||||
_attachments.DeleteTaskDir(taskId);
|
||||
}
|
||||
|
||||
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
|
||||
@@ -87,6 +93,22 @@ public sealed class TaskRepository
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all tasks that qualify as "real" Idle backlog items for online mirroring:
|
||||
/// Status==Idle, no parent, PlanningPhase==None, not blocked.
|
||||
/// </summary>
|
||||
public async Task<List<TaskEntity>> GetAllIdleBacklogAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Tasks
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Status == TaskStatus.Idle
|
||||
&& t.ParentTaskId == null
|
||||
&& t.PlanningPhase == PlanningPhase.None
|
||||
&& t.BlockedByTaskId == null)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Status transitions
|
||||
@@ -167,6 +189,7 @@ public sealed class TaskRepository
|
||||
string? systemPrompt,
|
||||
string? agentPath,
|
||||
int? maxTurns = null,
|
||||
string? sessionSkills = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
@@ -175,7 +198,8 @@ public sealed class TaskRepository
|
||||
.SetProperty(t => t.Model, model)
|
||||
.SetProperty(t => t.SystemPrompt, systemPrompt)
|
||||
.SetProperty(t => t.AgentPath, agentPath)
|
||||
.SetProperty(t => t.MaxTurns, maxTurns), ct);
|
||||
.SetProperty(t => t.MaxTurns, maxTurns)
|
||||
.SetProperty(t => t.SessionSkills, sessionSkills), ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -197,6 +221,7 @@ public sealed class TaskRepository
|
||||
string? description,
|
||||
string? commitType,
|
||||
string? createdBy = null,
|
||||
string? model = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which
|
||||
@@ -223,6 +248,7 @@ public sealed class TaskRepository
|
||||
ParentTaskId = parentId,
|
||||
SortOrder = (maxSort ?? -1) + 1,
|
||||
CreatedBy = createdBy,
|
||||
Model = ModelRegistry.NormalizeAlias(model),
|
||||
};
|
||||
_context.Tasks.Add(child);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
@@ -474,32 +500,5 @@ public sealed class TaskRepository
|
||||
return chainIds.Count;
|
||||
}
|
||||
|
||||
public async Task TryCompleteParentAsync(
|
||||
string parentId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||
if (parent is null || parent.PlanningPhase != PlanningPhase.Finalized) return;
|
||||
|
||||
var children = await _context.Tasks
|
||||
.Where(t => t.ParentTaskId == parentId)
|
||||
.Select(t => t.Status)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (children.Count == 0) return;
|
||||
|
||||
bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed);
|
||||
if (!allTerminal) return;
|
||||
|
||||
bool anyFailed = children.Any(s => s == TaskStatus.Failed);
|
||||
var finalStatus = anyFailed ? TaskStatus.Failed : TaskStatus.Done;
|
||||
var finishedAt = DateTime.UtcNow;
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == parentId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, finalStatus)
|
||||
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Seeding;
|
||||
@@ -9,17 +8,18 @@ public static class DefaultListsSeeder
|
||||
|
||||
public static async Task SeedAsync(ClaudeDoDbContext ctx, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await ctx.Lists.Select(l => l.Name).ToListAsync(ct);
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var name in Defaults.Where(n => !existing.Contains(n)))
|
||||
foreach (var name in Defaults)
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = name,
|
||||
CreatedAt = now,
|
||||
});
|
||||
var id = Guid.NewGuid().ToString();
|
||||
// Atomic conditional insert: the SELECT ... WHERE NOT EXISTS is a single
|
||||
// SQLite statement and cannot race — only one writer holds the lock.
|
||||
await ctx.Database.ExecuteSqlAsync(
|
||||
$"""
|
||||
INSERT INTO lists (id, name, created_at, default_commit_type, sort_order)
|
||||
SELECT {id}, {name}, {now}, 'chore', 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM lists WHERE name = {name})
|
||||
""", ct);
|
||||
}
|
||||
await ctx.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for the text handed to Claude as a task prompt:
|
||||
/// title + description + the OPEN sub-tasks. Resolved sub-tasks are dropped.
|
||||
/// Shared by the Worker (real prompt) and the UI (the card's "what Claude gets" preview).
|
||||
/// </summary>
|
||||
public static class TaskPromptComposer
|
||||
{
|
||||
public static string Compose(string title, string? description, IEnumerable<(string Title, bool Completed)> subtasks,
|
||||
IEnumerable<string>? attachmentPaths = null)
|
||||
{
|
||||
var sb = new StringBuilder((title ?? "").Trim());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
sb.Append("\n\n").Append(description.Trim());
|
||||
|
||||
var open = subtasks?.Where(s => !s.Completed).ToList() ?? new List<(string, bool)>();
|
||||
if (open.Count > 0)
|
||||
{
|
||||
sb.Append("\n\n## Sub-Tasks\n");
|
||||
foreach (var s in open)
|
||||
sb.Append("- [ ] ").Append(s.Title).Append('\n');
|
||||
}
|
||||
|
||||
var paths = attachmentPaths?.ToList();
|
||||
if (paths is { Count: > 0 })
|
||||
{
|
||||
sb.Append("\n\n## Reference files\nThese files were attached to this task as read-only reference (they live outside the repo). Read them as needed:\n");
|
||||
foreach (var p in paths)
|
||||
sb.Append("- ").Append(p).Append('\n');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -38,104 +38,6 @@ public partial class App : Application
|
||||
var localizer = new Localizer(localeStore, initialLang);
|
||||
TrExtension.Localizer = localizer;
|
||||
|
||||
// --- Self-update pre-flight ---
|
||||
// Resolve current exe path. Assembly.Location may point to a .dll for apphost-based
|
||||
// .NET apps; swap to the .exe companion when that happens.
|
||||
var currentExePath = Assembly.GetEntryAssembly()!.Location;
|
||||
if (currentExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
currentExePath = System.IO.Path.ChangeExtension(currentExePath, ".exe");
|
||||
}
|
||||
|
||||
// Arg form: --replace-self "<old-path>"
|
||||
var replaceSelfIndex = Array.FindIndex(e.Args, a => a.Equals("--replace-self", StringComparison.OrdinalIgnoreCase));
|
||||
if (replaceSelfIndex >= 0 && replaceSelfIndex + 1 < e.Args.Length)
|
||||
{
|
||||
var oldPath = e.Args[replaceSelfIndex + 1];
|
||||
var relaunched = await SelfUpdater.HandleReplaceSelfAsync(
|
||||
oldPath: oldPath,
|
||||
currentExePath: currentExePath,
|
||||
launchProcess: path =>
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
});
|
||||
if (relaunched)
|
||||
{
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
// Replacement failed — fall through to normal wizard from the temp location.
|
||||
}
|
||||
else
|
||||
{
|
||||
// Normal launch: check for a newer installer.
|
||||
using var selfUpdateHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
var selfUpdateReleases = new ReleaseClient(selfUpdateHttp);
|
||||
var currentVersion = GetInstallerVersion();
|
||||
|
||||
var decision = await SelfUpdater.DecideUpdateAsync(selfUpdateReleases, currentVersion, CancellationToken.None);
|
||||
if (decision.Kind == SelfUpdateDecisionKind.UpdateAvailable)
|
||||
{
|
||||
var prompt = new SelfUpdatePromptWindow(currentVersion, decision.LatestVersion!);
|
||||
DarkTitleBar.Apply(prompt);
|
||||
var ok = prompt.ShowDialog() == true;
|
||||
if (!ok)
|
||||
{
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
if (prompt.Choice == SelfUpdateChoice.Update)
|
||||
{
|
||||
prompt.ShowProgress("Downloading...");
|
||||
var tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ClaudeDo.Installer.Update");
|
||||
var verifiedPath = await SelfUpdater.DownloadAndVerifyAsync(
|
||||
selfUpdateReleases,
|
||||
decision.InstallerAsset!,
|
||||
decision.ChecksumsAsset!,
|
||||
tempDir,
|
||||
new Progress<long>(_ => { }),
|
||||
CancellationToken.None);
|
||||
|
||||
if (verifiedPath is null)
|
||||
{
|
||||
MessageBox.Show(prompt,
|
||||
"Update download or verification failed. Continuing with current installer.",
|
||||
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo(verifiedPath)
|
||||
{
|
||||
UseShellExecute = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--replace-self");
|
||||
psi.ArgumentList.Add(currentExePath);
|
||||
System.Diagnostics.Process.Start(psi);
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(prompt,
|
||||
"Failed to launch updated installer: " + ex.Message + "\nContinuing with current installer.",
|
||||
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
// SelfUpdateChoice.Continue — fall through to normal wizard.
|
||||
}
|
||||
// No-update or check failed — fall through to normal wizard.
|
||||
}
|
||||
|
||||
// --- Existing wizard start-up unchanged below this line ---
|
||||
|
||||
_services = BuildServices(localizer);
|
||||
|
||||
var context = _services.GetRequiredService<InstallContext>();
|
||||
|
||||
@@ -12,14 +12,22 @@ Note: this is the one project where `System.Windows` is correct (WPF, not Avalon
|
||||
- Entry point: `App.xaml` / `App.xaml.cs` (no `Program.cs`)
|
||||
- References: `ClaudeDo.Data`, `ClaudeDo.Releases`, `ClaudeDo.Localization`
|
||||
- Manifests: `app.manifest` (requireAdministrator, Release) / `app.debug.manifest` (asInvoker, Debug)
|
||||
- Only CLI arg: `--replace-self <old-path>` (self-update handoff)
|
||||
- No CLI args — mode is detected from `install.json` + the Gitea API
|
||||
|
||||
## Startup Sequence (`App.OnStartup`)
|
||||
|
||||
1. Load locale
|
||||
2. Self-update preflight — `SelfUpdater.DecideUpdateAsync` checks Gitea API; if a newer installer exists, download + checksum verify + relaunch with `--replace-self <old-path>`
|
||||
3. Detect mode — `InstallModeDetector` reads `install.json` + Gitea API
|
||||
4. Open `WizardWindow` (FreshInstall / Update) or `SettingsWindow` (Config)
|
||||
2. Detect mode — `InstallModeDetector` reads `install.json` + Gitea API
|
||||
3. Open `WizardWindow` (FreshInstall / Update) or `SettingsWindow` (Config)
|
||||
|
||||
The installer does **not** self-update. Each release ships a stable-named
|
||||
`ClaudeDo.Installer.exe` asset (permanent URL
|
||||
`…/releases/latest/download/ClaudeDo.Installer.exe`); the installer never checks for or
|
||||
replaces itself on launch. The in-app "Update" button relaunches the on-disk installer to
|
||||
run the app update — the installer binary itself only changes when the user downloads a
|
||||
fresh copy. App-update detection is unaffected: `WriteInstallManifestStep` records
|
||||
`ctx.InstalledVersion` (the release tag from `DownloadAndExtractStep`), which
|
||||
`InstallModeDetector` compares against the latest tag.
|
||||
|
||||
## Modes (`Core/InstallerMode.cs`)
|
||||
|
||||
@@ -56,8 +64,7 @@ Installer/
|
||||
Interfaces/ — IInstallStep + StepResult/StepStatus/StepProgress, IInstallerPage
|
||||
Pages/ — WelcomePage, PathsPage, ServicePage, UiSettingsPage, InstallPage
|
||||
(each: ViewModel + View.xaml)
|
||||
Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel),
|
||||
SelfUpdatePromptWindow
|
||||
Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel)
|
||||
```
|
||||
|
||||
## Key Step Behaviors
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 57 KiB |
@@ -1,5 +1,6 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Threading;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Releases;
|
||||
|
||||
@@ -77,10 +78,23 @@ public sealed class DownloadAndExtractStep : IInstallStep
|
||||
var appBak = appDest + ".bak";
|
||||
var workerBak = workerDest + ".bak";
|
||||
|
||||
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
|
||||
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true);
|
||||
if (Directory.Exists(appDest)) Directory.Move(appDest, appBak);
|
||||
if (Directory.Exists(workerDest)) Directory.Move(workerDest, workerBak);
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(appBak)) DeleteWithRetry(appBak);
|
||||
if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak);
|
||||
if (Directory.Exists(appDest)) MoveWithRetry(appDest, appBak);
|
||||
if (Directory.Exists(workerDest)) MoveWithRetry(workerDest, workerBak);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// A just-stopped app/worker (or an Explorer/terminal window sitting in
|
||||
// the install dir) still held a handle. Surface an actionable message
|
||||
// instead of the raw "process cannot access the file" error.
|
||||
return StepResult.Fail(
|
||||
"Could not replace the existing app/worker files — they are still in use. " +
|
||||
"Make sure ClaudeDo is fully closed (app and worker) and no Explorer or " +
|
||||
$"terminal window is open inside the install folder, then run the update again. Details: {ex.Message}");
|
||||
}
|
||||
|
||||
progress.Report("Extracting...");
|
||||
Directory.CreateDirectory(ctx.InstallDirectory);
|
||||
@@ -91,17 +105,17 @@ public sealed class DownloadAndExtractStep : IInstallStep
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Roll back to previous binaries.
|
||||
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
|
||||
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
|
||||
if (Directory.Exists(appBak)) Directory.Move(appBak, appDest);
|
||||
if (Directory.Exists(workerBak)) Directory.Move(workerBak, workerDest);
|
||||
if (Directory.Exists(appDest)) DeleteWithRetry(appDest);
|
||||
if (Directory.Exists(workerDest)) DeleteWithRetry(workerDest);
|
||||
if (Directory.Exists(appBak)) MoveWithRetry(appBak, appDest);
|
||||
if (Directory.Exists(workerBak)) MoveWithRetry(workerBak, workerDest);
|
||||
return StepResult.Fail(
|
||||
$"Extraction failed; previous binaries have been restored: {ex.Message}.");
|
||||
}
|
||||
|
||||
// Success — drop stash.
|
||||
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
|
||||
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true);
|
||||
if (Directory.Exists(appBak)) DeleteWithRetry(appBak);
|
||||
if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak);
|
||||
|
||||
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
|
||||
return StepResult.Ok();
|
||||
@@ -111,4 +125,26 @@ public sealed class DownloadAndExtractStep : IInstallStep
|
||||
try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static void MoveWithRetry(string source, string dest)
|
||||
=> RetryIo(() => Directory.Move(source, dest));
|
||||
|
||||
private static void DeleteWithRetry(string dir)
|
||||
=> RetryIo(() => Directory.Delete(dir, recursive: true));
|
||||
|
||||
// WaitForExit returns before Windows releases a just-killed process's file
|
||||
// handles, so the stash Move/Delete can briefly hit a sharing violation.
|
||||
// Retry through transient IO/access errors (~5s) before letting it surface.
|
||||
private static void RetryIo(Action action)
|
||||
{
|
||||
const int attempts = 10;
|
||||
for (var i = 0; ; i++)
|
||||
{
|
||||
try { action(); return; }
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException && i < attempts - 1)
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,22 +19,33 @@ public sealed class StopWorkerStep : IInstallStep
|
||||
{
|
||||
progress.Report("Stopping ClaudeDo processes (if running)...");
|
||||
var installDir = ctx.InstallDirectory;
|
||||
var killedAny = false;
|
||||
foreach (var name in ProcessNames)
|
||||
{
|
||||
foreach (var p in Process.GetProcessesByName(name))
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = p.MainModule?.FileName;
|
||||
// Scope to THIS install when the module path is readable; if it
|
||||
// can't be read (access race / exiting process), fall through and
|
||||
// kill anyway — a survivor would lock the install dir during
|
||||
// extraction. Reading MainModule must not skip the Kill.
|
||||
string? path = null;
|
||||
try { path = p.MainModule?.FileName; } catch { /* unreadable — kill anyway */ }
|
||||
if (path is not null && !IsUnder(path, installDir)) continue;
|
||||
p.Kill(entireProcessTree: true);
|
||||
p.WaitForExit(10000);
|
||||
killedAny = true;
|
||||
}
|
||||
catch { /* process may have exited or be inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
|
||||
// WaitForExit returns before the OS releases the process's file handles.
|
||||
// Give it a moment so DownloadAndExtractStep's Directory.Move doesn't race
|
||||
// a still-open handle. (That step also retries, this just avoids the churn.)
|
||||
if (killedAny) await Task.Delay(1500, ct);
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ public sealed class WriteUninstallRegistryStep : IInstallStep
|
||||
// the single-file temp extract is gone once this process exits.
|
||||
var sourceExe = Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Cannot resolve running installer path.");
|
||||
// In the self-update path the installer already runs from uninstaller/ (the
|
||||
// --replace-self handoff put it there), so source == target and the copy would
|
||||
// throw. Skip it; the binary is already in place.
|
||||
// When relaunched from the installed copy (e.g. the Apps & Features "Rerun
|
||||
// Installer" entry points at uninstaller/ClaudeDo.Installer.exe), source == target
|
||||
// and the copy would throw. Skip it; the binary is already in place.
|
||||
var alreadyInPlace = string.Equals(
|
||||
Path.GetFullPath(sourceExe), Path.GetFullPath(targetExe), StringComparison.OrdinalIgnoreCase);
|
||||
if (!alreadyInPlace)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<Window x:Class="ClaudeDo.Installer.Views.SelfUpdatePromptWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:loc="clr-namespace:ClaudeDo.Installer.Localization"
|
||||
Title="ClaudeDo Installer Update"
|
||||
Width="460" Height="200"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
ResizeMode="NoResize"
|
||||
Background="#1a1a1a" Foreground="#f0f0f0">
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="0" FontSize="16" FontWeight="SemiBold" Text="{loc:Tr installer.selfUpdate.heading}"/>
|
||||
<TextBlock Grid.Row="1" Margin="0,8,0,0" TextWrapping="Wrap" x:Name="DetailText"/>
|
||||
<TextBlock Grid.Row="2" Margin="0,12,0,0" TextWrapping="Wrap" Foreground="#a0a0a0" x:Name="ProgressText" Visibility="Collapsed"/>
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button x:Name="UpdateBtn" Content="{loc:Tr installer.selfUpdate.update}" MinWidth="90" Margin="4,0" Padding="10,4" Click="UpdateBtn_Click" IsDefault="True"/>
|
||||
<Button x:Name="ContinueBtn" Content="{loc:Tr installer.selfUpdate.continueAnyway}" MinWidth="140" Margin="4,0" Padding="10,4" Click="ContinueBtn_Click"/>
|
||||
<Button x:Name="CancelBtn" Content="{loc:Tr installer.nav.cancel}" MinWidth="90" Margin="4,0" Padding="10,4" Click="CancelBtn_Click" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user