8 Commits

334 changed files with 5070 additions and 30794 deletions

View File

@@ -1,101 +0,0 @@
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

View File

@@ -1,85 +0,0 @@
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

View File

@@ -5,10 +5,6 @@ on:
tags: tags:
- 'v*' - 'v*'
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -42,52 +38,11 @@ jobs:
TAG: ${{ steps.ver.outputs.tag }} TAG: ${{ steps.ver.outputs.tag }}
run: | run: |
set -euo pipefail set -euo pipefail
# Full clone (with tags) so release notes can diff against the previous tag. git clone --depth 1 --branch "$TAG" \
git clone --branch "$TAG" \
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \ "https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
"$WORK/src" "$WORK/src"
git -C "$WORK/src" log -1 --oneline git -C "$WORK/src" log -1 --oneline
- name: Generate release notes
env:
WORK: ${{ steps.ws.outputs.dir }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
cd "$WORK/src"
PREV="$(git tag --sort=v:refname | grep -E '^v' \
| awk -v t="$TAG" '$0==t{print prev} {prev=$0}')"
if [ -n "$PREV" ]; then
RANGE="${PREV}..${TAG}"
else
RANGE="$TAG"
fi
emit_group() {
# $1 conventional-type, $2 heading
local lines
lines="$(git log "$RANGE" --no-merges --pretty=format:'%s|%h' \
| grep -E "^${1}(\([^)]*\))?(!)?: " || true)"
[ -z "$lines" ] && return 0
printf '### %s\n\n' "$2"
while IFS='|' read -r subject hash; do
printf -- '- %s (%s)\n' "${subject#*: }" "$hash"
done <<< "$lines"
printf '\n'
}
{
emit_group feat "Features"
emit_group fix "Fixes"
emit_group perf "Performance"
emit_group refactor "Refactoring"
emit_group docs "Documentation"
} > RELEASE_NOTES.md
echo "--- release notes ---"
cat RELEASE_NOTES.md
- name: Publish ClaudeDo.App (win-x64, self-contained) - name: Publish ClaudeDo.App (win-x64, self-contained)
env: env:
WORK: ${{ steps.ws.outputs.dir }} WORK: ${{ steps.ws.outputs.dir }}
@@ -145,19 +100,18 @@ jobs:
ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip" ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker ) ( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker )
# 2) Installer single-file exe — STABLE name (no version) so the download URL # 2) Installer single-file exe (renamed)
# (…/releases/latest/download/ClaudeDo.Installer.exe) stays permanent.
INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1) INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1)
if [ -z "$INSTALLER_EXE" ]; then if [ -z "$INSTALLER_EXE" ]; then
echo "::error::No .exe produced by installer publish" >&2 echo "::error::No .exe produced by installer publish" >&2
exit 1 exit 1
fi fi
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer.exe" cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe"
# 3) Checksums (sha256, relative filenames) # 3) Checksums (sha256, relative filenames)
( cd assets && sha256sum \ ( cd assets && sha256sum \
"ClaudeDo-${VERSION}-win-x64.zip" \ "ClaudeDo-${VERSION}-win-x64.zip" \
"ClaudeDo.Installer.exe" \ "ClaudeDo.Installer-${VERSION}.exe" \
> checksums.txt ) > checksums.txt )
echo "--- assets ---" echo "--- assets ---"
@@ -174,8 +128,7 @@ jobs:
BODY=$(jq -n \ BODY=$(jq -n \
--arg tag "$TAG" \ --arg tag "$TAG" \
--arg name "$TAG" \ --arg name "$TAG" \
--rawfile body "$WORK/src/RELEASE_NOTES.md" \ '{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
'{tag_name:$tag, name:$name, body:$body, draft:true, prerelease:false, target_commitish:"main"}')
RESP=$(curl -sS -X POST \ RESP=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -201,7 +154,7 @@ jobs:
cd "$WORK/src/assets" cd "$WORK/src/assets"
for f in \ for f in \
"ClaudeDo-${VERSION}-win-x64.zip" \ "ClaudeDo-${VERSION}-win-x64.zip" \
"ClaudeDo.Installer.exe" \ "ClaudeDo.Installer-${VERSION}.exe" \
"checksums.txt" "checksums.txt"
do do
echo "Uploading: $f" echo "Uploading: $f"
@@ -213,32 +166,6 @@ jobs:
done done
echo "All assets uploaded." echo "All assets uploaded."
- name: Publish release
env:
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
curl -sS --fail-with-body -X PATCH \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"draft":false}' \
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}" \
> /dev/null
echo "Release ${RELEASE_ID} published."
- name: Delete draft release on failure
if: failure() && steps.release.outputs.release_id != ''
env:
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
curl -sS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}" \
> /dev/null || true
echo "Cleaned up draft release ${RELEASE_ID}."
- name: Cleanup workspace - name: Cleanup workspace
if: always() if: always()
env: env:

4
.gitignore vendored
View File

@@ -1,9 +1,5 @@
# Local dev worktrees (created by using-git-worktrees skill) # Local dev worktrees (created by using-git-worktrees skill)
.worktrees/ .worktrees/
.claude/worktrees/
# Brainstorming visual companion artifacts
.superpowers/
# .NET build output # .NET build output
bin/ bin/

View File

@@ -1,970 +0,0 @@
# Changelog
## v1.9.0 — 2026-06-19
### Features
- diff Merge opens the 3-pane editor + conflict overview ruler (29a294b)
- toggle add/remove per side, MAIN/INCOMING labels, files readout (ca4377e)
- additive conflict accept — stack ours/theirs in click order (d5eec75)
- add accept-both control to the 3-pane conflict gutter (18479c0)
- Rider-style 3-pane conflict editor view (c4d1acc)
- unify planning conflicts onto the resolver + 3-pane VM foundation (378a92c)
- move review feedback to the Output tab + review/worktree polish (3e4e4a0)
- in-app 3-way merge editor (chunk 2b) (92767c6)
- real conflict-hunk parsing pipeline (chunk 2 backend) (e779e13)
- My Day actions, orphan-aware grouping, menu restructure (4847c5c)
- unify review actions into the Git-tab cockpit (43fb506)
- carry ownerId on sync to prepare for multi-user (cee051b)
- gate access on Zitadel "user" project role (23c3065)
- Online Inbox settings tab + auth-code/PKCE login (80a2de6)
- Online Inbox config + auth hub plumbing (Phase 2) (17c7ff5)
- real ZitadelAuthProvider (refresh-token grant, auth-code+PKCE) (619bc0c)
- Online Inbox sync engine (Phase 1) (1ac9ced)
- let Claude set the cheapest model per generated task via MCP (c27a179)
### Fixes
- unresolved conflicts compose to empty, not Ours (+ review nits) (23a93ce)
- harden 3-pane editor + document the new conflict resolver (869dd25)
- invalidate cached access token when the signed-in user changes (cfe23cd)
- preserve API base path in Online Inbox client (8b347de)
- queue dispatches skip the StartRunning re-claim (74ca2e0)
- document and test Queued→Failed guard in FailAsync (fe73f45)
- stateless AbortPlanningMerge after worker restart mid-merge (fb1d799)
- route FinalizeParentDoneAsync through TaskStateService (e9e4ad8)
### Refactoring
- bring IWorkerClient to parity with WorkerClient (b5417f6)
### Documentation
- spec + plan for Rider-style 3-pane merge editor (983c177)
- KunsZitadel is server-side only; desktop uses an OIDC client flow (96da9fb)
- API contract, desktop design spec, and implementation plan (8cbe1ad)
- close out the review round in open.md, sync CLAUDE.md with merges (23ff391)
- record correctness-review findings (4 confirmed as tasks) (ddeded9)
- record review findings as refactoring backlog (1448794)
- spec + plan for per-task model override via MCP (51ef488)
- refresh CLAUDE.md files and open.md to current code state (4904631)
- update for v1.8.0 (f8f20bf)
## v1.8.0 — 2026-06-09
### Features
- richer diff viewer + surface child roadblocks on parents (f21c65b)
- allow cancelling a WaitingForChildren parent (d6e0953)
- single approve action merges the whole unit (a8b86e2)
- approve drives the unit merge for parents with children (1abb429)
- planning finalize enters WaitingForChildren (12732d6)
- add get_task_config, continue_task; fix status enum, branchDeleted, merge-from-review (9f19a71)
- surface agent roadblocks and run outcome in the detail pane (763732a)
- localize task-header, task-row and prime-schedule tooltips (a41b8de)
- wire layer A/B conflict seams to the inline resolver (7f173da)
- expose conflict-resolver factory and dialog seam for integrator (72687e9)
- add inline conflict resolver view and localization (8cafad3)
- add inline conflict resolver view-model (d8a973d)
- add inline conflict model (file/hunk with resolution) (0b623b8)
- batch-merge cockpit view with checkboxes and conflicts panel (5edb433)
- add batch-merge cockpit strings (en/de) (c8f82ed)
- wire batch selection, target loading and resolve seam (1aa0607)
- expose conflict-resolution merge methods (cb20877)
- read conflict stages and write user resolutions (dcbf67c)
- add skip-and-continue batch merge orchestration (02b11c7)
- add conflict-stage blob reads and single-path staging (74afc46)
- add batch-merge row state to worktrees cockpit VM (ef3fba1)
- fuse git tab into one approve+merge cockpit (3596053)
- route single-task merge conflicts into a resolution seam (4bf4a27)
- maximize work console via green traffic-light dot (de4ad5d)
- add conflict-resolution worker contract (foundation for merge rework) (2dfc455)
- rename review Retry to Continue and make Reset discard the worktree (42bb79e)
- send Retry on Enter in the review prompt (9c5872e)
- rework review into terminal footer and add Git tab (8819a56)
- add IsGitTab flag to work console view model (6c65158)
- add mergeability indicator and Merge button to work console (de01579)
- show mergeability and surface approve conflicts in the work console (0d8999d)
- wire merge-aware approve and preview into the worker client (3202c76)
- expose PreviewMerge hub method and merge-on-approve (43f8f7f)
- approve merges worktree before marking task done (b817c87)
- add Refine button, icon, and command to task card (2a6781f)
- add non-destructive merge-tree conflict probe (4098f7f)
- add RefineTask client call and refine events (8239004)
- wire RefineTask hub method, broadcaster events, and DI (e523ed8)
- add RefineRunner, prompt/args helper, and interfaces (0460d7b)
- add Refine prompt kind and default (eca6813)
- add add_subtask tool to claudedo MCP (22830d3)
- resize detail split by dragging the console's top edge (b840655)
- rework work console — single Session tab, right-aligned header, turns x/y (ac9bae9)
- make steps visible at a glance; lift details card off background (99c6bf4)
- wire redesigned detail island (header + description/steps card + work console) (c71026d)
- add WorkConsole detail component (ce50f9f)
- add DescriptionStepsCard detail component (c323953)
- add TaskHeaderBar detail component (9f95942)
- compose task prompt from title + description + open steps only (299867d)
- fold parent branch into combined-diff for improvement parents (469e68b)
- focused custom prompt for improvement children so they stay narrow (176b985)
- show improvement-child outcomes on the parent review card + enable tree-merge (5d34f95)
- mark agent-suggested improvement children in the task tree (0e13017)
- surface WaitingForChildren status (chip, color, agent-strip, labels) (5363570)
- instruct agents to offload out-of-scope work via SuggestImprovement (f60beca)
- fold parent branch into tree-merge for improvement parents (519bfbe)
- mint per-run MCP token + emit run-scoped --mcp-config (06e3acd)
- resolve per-run tokens in MCP auth + register TaskRunMcpService (f3052dc)
- add SuggestImprovement tool (server-stamped, one layer deep) (9d133e2)
- add TaskRunMcpContext + accessor (7542bc2)
- add per-run TaskRunTokenRegistry (ef86a8c)
- base improvement-child worktree on parent HEAD (da23b6c)
- route standalone success with children to WaitingForChildren + enqueue them (c10f564)
- advance WaitingForChildren parent to review when children terminal (7873e60)
- add SubmitForChildrenAsync (Running -> WaitingForChildren) (6f4b5d5)
- generalize CreateChildAsync for any parent + CreatedBy stamp (6fdf04d)
- add WaitingForChildren task status value (ee0d125)
- roadblock badge on the task card; relocate review actions off the row (2455eac)
- host review actions in the details panel; show review state and diff meter (d8b86e3)
- persist roadblock count on the task (49b9f1f)
- surface reported roadblocks in the review result (1e547de)
- carry blocks through RunResult (56ebc28)
- collect and strip CLAUDEDO_BLOCKED markers in StreamAnalyzer (cf7f0da)
- weekly-report instructions from file, point at data sections (ac1e9b0)
- daily-prep prompt from file, English default (79bfc79)
- expose all editable prompt files, drop agent prompt (bd1e3db)
- retry prompt from file, append only real captured errors (edc9f77)
- externalize prompt kinds with defaults and token renderer (9bdf99d)
- show inherited markers and max-turns override in task flyout (cd683ba)
- show inherited markers and max-turns override in list settings (d0ab382)
- add reusable inherited-source badge control (3e3041c)
- add inheritance resolver returning value and source (92cee12)
- add inherited-marker, turns, and prepended-prompt strings (bba3c55)
- mirror max-turns field on signalr config dtos (26f5936)
- expose max-turns override over signalr and mcp config tools (b72a788)
- resolve max-turns from task then list then global default (beae2d6)
- persist max_turns in list and task repositories (ac137f7)
- add nullable max_turns override to list_config and tasks (97e38fb)
- replace Plan-My-Day header icon with a stroked sun icon (52e3980)
- trigger planning from inside the prep-log window with an empty-state hint (7d743f1)
- load persisted prep log into the terminal on open (914095d)
- persist last prep run to a log file and serve it via GetLastPrepLog (4d82079)
- move Clear-day and Prep-log into MyDay header icon row (c764b2b)
- reuse SessionTerminal for prep log; fix invisible Sort icon; add Broom/List icons (f7d1b37)
- clear textbox focus on click outside any text box (fab1772)
- add Prep-log and Clear-day buttons to MyDay header (c45f892)
- add live prep-output mode to the Details island (a8670ee)
- expose prep stream events and ClearMyDay on the UI worker client (7676ecf)
- add ClearMyDay hub method (fa83d7f)
- stream prep output via PrepStarted/PrepLine/PrepFinished (e48475d)
- add Prepare-day button to MyDay header (46ac3fc)
- add DailyPrepMaxTasks editor to Prime Claude settings (5e0859f)
- add RunDailyPrepNow hub method and expose DailyPrepMaxTasks (2d00160)
- run daily prep from PrimeRunner via allowed MCP tools (20b3a29)
- add set_my_day MCP tool with cap-guard (fd7f8ac)
- add get_daily_prep_candidates MCP tool (0bb8094)
- add DailyPrepMaxTasks app setting (3c66d65)
### Fixes
- mark task Done on every successful merge path, not just approve (f56cc61)
- merge_task marks the task Done after a successful merge (ca8326c)
- drop unique index on lists.name (allow duplicate list names) (f5d165b)
- harden FK pragma per-connection and seed concurrency (7f1a14a)
- harden CLI injection, stuck-Running, chain wedge, and Fail guard (33bdff8)
- serialize concurrent worktree add to prevent commondir race (b672c9a)
- dispose VM subscriptions/timers, guard offline Stop, align review delta-path (01e0c1d)
- populate review queue from WaitingForReview tasks (00a065b)
- set prompt-action resting color on ContentPresenter (561028e)
- discard stale mergeability probe after task or target switch (6e3f90d)
- guard blank working dir in approve-merge before resolving target (f1cf29b)
- update TaskMergeService ctor calls after ITaskStateService injection (98b0d58)
- stop the console clipping the last log line (1603be0)
- render Output log directly on the console, not as a nested card (71a3765)
- stop app crash when approving review after Merge all (cc7355e)
- live-update child outcomes + enable Review combined diff for improvement parents (a3f407b)
- only planning-active children are drafts; allow improvement children to queue (8036de1)
- exempt improvement children from orphan-dequeue sweep (f25c759)
- populate diff meter when selecting a finished task (c035720)
- warning icon fill-rule and dedicated review section header (4522ac9)
- apply system default on every run; dedupe roadblocks (9a117a5)
- clean up orphaned worktree when the DB row insert fails (71ac481)
- hide task header, footer and agent strip in prep/notes mode (39fa83a)
- register IWorkerClient mapping for WeeklyReportModalViewModel (46f42a4)
### Refactoring
- single parent-advance path for planning + improvement (b3a2daf)
- render worktree modal diff via canonical DiffLinesView (d52243c)
- blend review prompt into the terminal instead of a boxed footer (e22a326)
- remove dead inline-layout handlers from DetailsIslandView (3e84871)
- share color-coded diff rendering between per-task and combined diff viewers (22a1ba7)
- planning prompts read from editable files (1b3c6bd)
- collapse agent prompt into system prompt (883dbc6)
- address code smells (run-dir helper, App DI injection) (3756b81)
- remove unused Sort button from MyDay header (3a40e39)
### Documentation
- document the unified parent-task model (c300f8c)
- Task 4 = full approve/merge UX consolidation (803c04d)
- spec + plan for unifying the parent-task model (8f49ebb)
- add CHANGELOG (Keep a Changelog format) (384e058)
- Layer C inline conflict resolver (ef2f5c5)
- Layer B multi-worktree merge cockpit plan (3060cb0)
- foundation + Layer A plan and Layer B/C parallel kickoff prompts (dd3b03b)
- git tab merge & review rework — shared foundation + 3 layers (f4416ee)
- add implementation plan for terminal-style review controls (096519b)
- spec terminal-style review with Git tab and footer actions (266e6d1)
- document real git merge on approve, PreviewMerge hub method, and new GitService/WorkerClient members (cb4c396)
- add approve-merge + conflict-preview implementation plan (75ad7b1)
- add approve-merge + conflict-preview design spec (66a7b23)
- add Refine Task implementation plan (3573548)
- add Refine Task design spec (0867bc8)
- add ClaudeDo distribution website design spec (a2c339c)
- task-detail island redesign spec + component build prompts (8f7e289)
- implementation plan for build-config logging + traceability (c5a4e35)
- runtime build-config detection, Warning in Release, retain 2 (e547921)
- design for build-config debug logging + task traceability (f1316df)
- align Task 6 with rebased HandleSuccess (preserve SetRoadblockCount) (204b089)
- child tasks + agent improvement loop implementation plan (da4ab0c)
- plan for review & roadblock UX follow-up (4d52845)
- refresh prompt inventory for externalized prompts + roadblock marker (202e8de)
- implementation plan for bundled-prompts overhaul (c8f468f)
- child base off parent HEAD, shared planning-style tree merge (84fd2c1)
- design for reusable child tasks + agent improvement loop (30b49d1)
- design for bundled-prompts overhaul (ad7d748)
- note max-turns override and inherited markers in module docs (75aa42b)
- implementation plan for inherited markers, overrides, and Turns (b63c78c)
- spec for inherited-settings display, overrides, and Turns (37ce673)
- slim open.md down to open items only (b9741ef)
- park mailbox proposal; skip architecture.md and ADRs (0a0d7e8)
- drop CI-pipeline item (push-to-main + release workflow makes it redundant) (72a86fc)
- regenerate open.md against verified current state (bcf5e2f)
- document daily-prep across area CLAUDE.md files; add Installer CLAUDE.md (fb055ce)
- add autonomous working-style loop and agent gotchas to CLAUDE.md (9e7f37b)
- add plan-day-in-log-window plan (53d897a)
- add prep-log persistence plan (26758b6)
- add MyDay icons + terminal-reuse plan (2e73d33)
- add design specs and implementation plans (9470c5b)
## v1.7.0 — 2026-06-03
### Features
- localize installer with language picker and config write-through (364a037)
- add WPF localization primitives and Language config to installer (2fbf054)
- localize ViewModel-built strings via ambient Loc accessor (350a89f)
- localize Avalonia view strings via loc:Tr markup (086c6f6)
- add language dropdown to settings and persist selection (070f5de)
- initialize Localizer at app startup from config/OS culture (f529a5f)
- add Language preference and Save() to AppSettings (6a85d82)
- add Avalonia loc:Tr markup extension and LocalizedString (35ad171)
- seed en.json and wire locale copy to app output (3c40bb5)
- add CultureResolver for OS-culture mapping (d95d55e)
- add Localizer with fallback chain and change event (d22b50e)
- add LocaleStore folder discovery (a83a0c4)
- add ClaudeDo.Localization project with nested-JSON locale parser (9efde2b)
- pinned Notes row in My Day opens the notes editor (a8943a9)
- notes mode in the Details island (eccd06e)
- NotesEditorView (731c291)
- NotesEditorViewModel with day navigation and bullet CRUD (c8b5ed3)
- INotesApi wrapper for daily notes (9bf44da)
- open Weekly Report modal from the menu (b748c15)
- WeeklyReportModalView (74fc39f)
- WeeklyReportModalViewModel with default-range logic (ccd2ee2)
- persist report excluded paths and standup weekday (5b89e3d)
- WorkerClient methods for week report and daily notes (e106b00)
- hub methods for week report and daily notes (d7558ef)
- register report reader and service in DI (4aa4353)
- WeekReportService orchestrates generate + store (50d84f1)
- week report prompt builder (day-major pivot) (e2271b5)
- ClaudeHistoryReader distills session logs (bec87b3)
- report activity models and reader interface (4cb7ad8)
- add WeekReportRepository with tests (992fbf0)
- add DailyNoteRepository with tests (1d7b86d)
- migration for daily notes and week reports (036586e)
- configure daily note + week report tables (d9e5d26)
- add daily note + week report entities and report settings (10d86b4)
### Fixes
- live-refresh smart/virtual list names on language change (00ef11a)
- notes add row stays visible, English 'Add' label, Enter to add (2d55f88)
- sanitize report model arg, fix multi-repo summary attribution and standup-weekday sentinel (a8d8a8b)
### Documentation
- localization implementation plan (8dc8b8b)
- localization (i18n) design spec (baeea9c)
- document weekly report and daily notes feature (0bc3d2a)
- add weekly report implementation plan (f72cfae)
- add report prompt and day-major pivot to weekly report spec (e5a2ed2)
- add weekly report feature design spec (536d819)
## v1.6.0 — 2026-06-02
### Features
- use a 24h TimePicker for prime schedule time entry (869cf72)
- replace prime date range with weekday toggle buttons (7db8f21)
- drive prime schedule rows from weekday toggles (37738e3)
- map prime schedule weekday bitmask over the hub (81fd186)
- compute prime due-time from weekday bitmask (bed4255)
- migrate prime schedules to days_of_week bitmask (dff06d9)
- persist weekday bitmask in prime schedule repo (0efad7a)
- model Prime schedule as weekday bitmask (eaf27e8)
- surface review actions and WaitingForReview status in task rows (6c27ffb)
- add review hub methods and worker client wrappers (21f1cf2)
- add review_task MCP tool and status reference updates (c88ed9d)
- route standalone success to review and resume on re-queue (9c1f20f)
- add review state transitions to TaskStateService (e8d018d)
- add WaitingForReview status and review_feedback column (1ca32a6)
### Fixes
- manual modal dragging, maximize/restore icon, day-toggle style (f1715a3)
- harden review re-run, timestamps, and queue affordance (1cb5171)
### Documentation
- describe recurring-weekday Prime schedule (26998f0)
- implementation plan for recurring-weekday Prime (13c3393)
- spec for recurring-weekday Prime schedules (4704a28)
- document WaitingForReview state across project CLAUDE.md files (4684a0a)
- waiting-for-review implementation plan (b86677d)
- waiting-for-review task state design (3e072fa)
## v1.5.0 — 2026-06-01
### Features
- replay run log in session terminal, drop per-row live tail (4a36fbe)
- optionally register ClaudeDo MCP server with Claude (5170914)
- configurable max parallel task executions (b1f4349)
- list reordering, quick actions, and resizable modals (ab44ba5)
## v1.4.2 — 2026-06-01
### Fixes
- stop the running app before updating, not just the worker (4148dcd)
- keep step badges green and reset state on re-run (5783790)
## v1.4.1 — 2026-06-01
### Fixes
- track EF migration Designer files (were gitignored) (edfb702)
## v1.4.0 — 2026-06-01
### Features
- wire worker connection modal and make status pill clickable (1246bf7)
- prompt once on worker connection failure with grace timer (00dc7eb)
- add worker connection help modal (0139607)
- remove Startup worker shortcut on uninstall (759d905)
- start worker via Process.Start, drop schtasks stop (2f1dcdc)
- register autostart via Startup shortcut, drop scheduled task (133f2d2)
- add AutostartShortcut helper for Startup-folder lnk (e2bb43a)
- unify type scale to 11/13/18/24 and add canonical text classes (b00e4d9)
- add reusable ModalShell control (c20fbe3)
- set global Inter Tight font default on all windows (5a25818)
- add named tint and hairline overlay brush tokens (f0f8cd1)
### Fixes
- restore resize and full-width rows in WorktreesOverview modal (16717ab)
- unclip Edit/Preview buttons; enlarge section labels and use mono field labels (e86464e)
- correct SettingsModal font snap (11px is Mono, not Body) (b1006ac)
- use LineBrush for schedule flyout border and tokenize TaskRowView (3d4a64a)
### Refactoring
- rename StopWorkerStep.TaskName to LegacyTaskName (400a078)
- stop auto-spawning the worker on app start (4ecd855)
- extract ShortcutFactory COM helper (867dc37)
- migrate PlanningDiffView to ModalShell (926471d)
- drop double padding in Tasks island header (9be8e6b)
- drop double padding in Lists island header (b9e5dfc)
- class schedule-flyout cancel in TaskRowView (c669370)
- class merge-section buttons in DetailsIslandView (4688e88)
- class update-banner buttons in MainWindow (8b21b0e)
- normalize buttons/footer/padding in ConflictResolutionView (4a786eb)
- normalize buttons/footer/padding in DiffModal (cd64f28)
- normalize buttons/footer/padding in WorktreesOverviewModal (3585ad5)
- normalize buttons/footer/padding in RepoImportModal (990935e)
- normalize buttons/footer/padding in UnfinishedPlanningModal (1b5a928)
- normalize buttons/footer/padding in AboutModal (e8f880e)
- normalize buttons/footer/padding in MergeModal (3228a08)
- normalize buttons/footer/padding in ListSettingsModal (ccec791)
- normalize buttons/footer/padding in SettingsModal (187fb64)
- make primary/danger buttons self-contained, drop unused btn.primary (0a71956)
- inherit terminal font for SelectableTextBlock (ccec591)
- use sidebar-pane in PlanningDiffView (a4cb03b)
- use diff-lineno and sidebar-pane in DiffModal (f53292e)
- use danger-box in MergeModal (539ebec)
- use danger-box in SettingsModal (dff5651)
- use shared section style in ListSettingsModal (9f49b01)
- reuse task-row style for worktree rows (fb3a6ac)
- use section-divider in DetailsIslandView (4f84b15)
- drop duplicate converters and normalize binding in ListsIslandView (27b0d51)
- merge task-row styles and add shared section/danger-box/sidebar/accent styles (2a38104)
- unify text and close button in ThemedDatePicker (bddef5a)
- unify text and close button in ConflictResolutionView (51d3ea2)
- unify text and close button in PlanningDiffView (335b422)
- unify text and close button in DiffModalView (08f3bab)
- unify text and close button in WorktreeModalView (9082f2e)
- unify text and close button in WorktreesOverviewModalView (0f64b1c)
- unify text and close button in RepoImportModalView (dd45387)
- unify text and close button in UnfinishedPlanningModalView (00e1d2d)
- unify text and close button in AboutModalView (9a91135)
- unify text and close button in MergeModalView (8e595a1)
- unify text and close button in ListSettingsModalView (97fc715)
- unify text and close button in SettingsModalView (ed8607d)
- apply text classes to SessionTerminalView (929e0ca)
- apply text classes to AgentStripView (40a3630)
- apply text classes to TaskRowView (b9f5d82)
- apply text classes to DetailsIslandView (e0dda3e)
- apply text classes to TasksIslandView (d4c66de)
- apply text classes to ListsIslandView (a132127)
- apply text classes to MainWindow (6e3125e)
- consolidate list-section-label into shared section-label (7af892f)
- fold selected-day White to TextBrush token (8944074)
- tokenize WorktreeModalView font sizes (fbd5d9f)
- tokenize and dynamic-ize PlanningDiffView (5fdd9f0)
- migrate ConflictResolutionView to ModalShell and use dynamic resources (bce4e0a)
- migrate DiffModal to ModalShell and use dynamic resources (229f865)
- migrate WorktreesOverviewModal to ModalShell (a444033)
- migrate RepoImportModal to ModalShell (2265829)
- migrate UnfinishedPlanningModal to ModalShell (50e05b9)
- migrate AboutModal to ModalShell (538839c)
- migrate MergeModal to ModalShell (8d07fc2)
- migrate ListSettingsModal to ModalShell (e1bfbb0)
- migrate SettingsModal to ModalShell (4f5db36)
- tokenize ThemedDatePicker (16b0d11)
- tokenize SessionTerminalView (a1f05da)
- tokenize AgentStripView (0c0c73b)
- tokenize DetailsIslandView (bff15c9)
- tokenize TasksIslandView (f40de4b)
- tokenize ListsIslandView (e120b0f)
- tokenize MainWindow (e8ce725)
- tokenize IslandStyles values and add shared modal styles (7a6bfbe)
### Documentation
- reflect Startup-shortcut worker autostart (549b87b)
- add worker lifecycle implementation plan (5baa1d7)
- add worker lifecycle redesign spec (4963a72)
- add visual-check checklist for normalization pass (df73378)
- add UI normalization design spec and implementation plan (d52f23f)
## v1.3.0 — 2026-05-30
### Features
- register new external MCP tool classes (b41a78e)
- add external MCP app-settings read tool (9ea6070)
- add external MCP reset-failed-task tool (5a592c4)
- add external MCP agent-listing tool (7196aab)
- add external MCP run-history and log tools (3afe29d)
- add external MCP list/task config tools (c3493a3)
- add external MCP list-management tools (53f4e2d)
- run worker as per-user logon task instead of Windows service (26c4e57)
- repo-import modal — remember folders, search, compact rows, no auto-select (6d0973c)
- add delete-list button to List Settings modal (128fb7d)
- add 'Add repos as lists' Help-menu entry point (9c638e7)
- add repo import button to Lists island (c43b06d)
- add RepoImportModalView (e4d958d)
- add RepoImportModalViewModel with candidate merge logic (50b1589)
- add RepoImportItemViewModel (1c689a8)
- add RepoScanner for git repo discovery (03617ee)
- gate subtask queueing behind plan finalization (ce79a2d)
- merge action and robust jump-to-task in worktrees overview (967e0cd)
- hide list chip outside virtual list views (2223839)
- auto-select first changed file in diff modal (3587703)
- polish worktrees overview modal (ca71275)
- wire worktree overview modal entry points (789094f)
- add WorktreesOverviewModalView (9f70f67)
- add WorktreesOverviewModalViewModel (182a9df)
- add WorktreeStateColorConverter (79131f8)
- expose worktree overview client methods (b888a5f)
- expose worktree overview, state mutation, force-remove (046da0f)
- add ForceRemoveAsync for targeted removal (b095a29)
- add GetOverviewAsync for overview modal (ce30d01)
- allow CleanupFinishedAsync to filter by list (89f6b83)
- add Restart worker menu entry under Help (8d34db3)
- prevent orphaned subtasks via guards + startup repair (d094a21)
- add Claude CLI preflight on startup (df66c4a)
- cascade dequeue to queued children for any parent (4c92da5)
- refine planning chain re-shape on re-run (d4d5a4b)
- status/tag context menu + ThemedDatePicker in task row (9ba238f)
- editable task status and tags from details panel (c185665)
- add ThemedDatePicker control and adopt in Prime settings (47b0737)
- add hub methods to set task status and tags freely (121e8cd)
- drop 'agent' tag gate from queue claim (cfbe2fd)
- show transient prime status in footer (5079a5f)
- add About modal opened from Help menu (618235d)
- refactor Settings to TabControl + add Prime Claude tab (bca8c9e)
- split SettingsModalViewModel into per-tab VMs + add PrimeClaudeTabViewModel (8b02b63)
- add Prime schedule client + PrimeFired event (f890fa8)
- register Prime services in DI (71c6c68)
- add Prime schedule hub methods (507f59f)
- broadcast PrimeFired SignalR event (13c280f)
- add PrimeScheduler hosted service (09e3e7e)
- add NextDueCalculator with workday + catch-up logic (975db8a)
- add Prime scheduler abstractions + runner (f383645)
- add PrimeScheduleDto (4e90828)
- add PrimeScheduleRepository (a335a3b)
- add AddPrimeSchedules migration (0b90df6)
- add PrimeScheduleEntity + configuration (6c9ccf6)
- consolidate finalize+chain via TaskStateService, fix queue pickup (4ab906f)
- add Idle/Cancelled status, PlanningPhase enum, BlockedByTaskId field (7b737e6)
- show dequeue affordance on planning parents with queued children (bdb709b)
- allow status changes and post-finalize edits in active session (2d7f825)
- add SetTaskTags (59dc1e2)
- add DeleteTask (31a394e)
- add UpdateTask for content/tag patching (d99cb68)
- AddTask accepts tags on creation (1a74e1c)
- add ListTags + inject TagRepository (e6846b7)
- add TaskRepository.SetTagsAsync for full tag-set replacement (2549352)
- default permission mode to auto and surface it in UI (14cc9fb)
- add editable system/planning/agent prompt files (7f96ae9)
- add Run interactively action to task context menu (6c54759)
- make island layout user-resizable with grid splitters (e192285)
- add MarkdownView control and editable description in details island (a6ca1c0)
- queue planning subtasks sequentially and surface waiting status (8f94ddd)
- add external MCP endpoint with API-key auth (4532042)
- add PlanningChainCoordinator for sequential subtask execution (16e1ddd)
- add Waiting task status and CreatedBy column (288d2ec)
- run planning agent in plan permission mode and enforce brainstorming skill (8e9f09a)
- register planning services and add Merge-all hub methods (3008c36)
- add pre-flight checks and idempotent restart to PlanningMergeOrchestrator (e58cac2)
- add PlanningMergeOrchestrator.AbortAsync (b989639)
- add PlanningMergeOrchestrator.ContinueAsync to resume merge after conflict (7d87c03)
- add PlanningMergeOrchestrator happy path with merge event broadcasts (3142ba2)
- add conflict resolution dialog for planning merge-all (bc788e1)
- add aggregated diff viewer for planning tasks (a6ebff3)
- add PlanningAggregator.CleanupIntegrationBranchAsync (389d904)
- add merge-target dropdown and merge-all controls to planning detail (4c6fd9f)
- add PlanningAggregator.BuildIntegrationBranchAsync (2cab33d)
- add PlanningAggregator.GetAggregatedDiffAsync (a1727b6)
- add AbortMergeAsync to cancel a conflicted merge (bc0f1e3)
- add ContinueMergeAsync to resume a conflicted merge (62106ff)
- add leaveConflictsInTree option to TaskMergeService.MergeAsync (e77ba35)
- broadcast child TaskUpdated events on planning CRUD (5a03dc8)
- launcher passes planning token via env, drops --mcp-config (6800852)
- cleanup planning worktree and branch on finalize/discard (48899b3)
- create ephemeral worktree and write .mcp.json in StartAsync (fce91bc)
- live task updates from worker events + planning polish (b7c60f5)
- SignalR hub endpoints for planning sessions (7b67e35)
- map MCP HTTP endpoint and broadcast TaskUpdated (6cb20a9)
- MCP tools update_planning_task and finalize (99c6a71)
- MCP tools for child-task CRUD (0088d6e)
- MCP bearer-token auth middleware (b115a4c)
- WindowsTerminalPlanningLauncher with pre-flight checks (43a3740)
- PlanningSessionManager.GetPendingDraftCountAsync (d28164c)
- PlanningSessionManager.FinalizeAsync (77f7cf1)
- PlanningSessionManager.DiscardAsync (84e6c2d)
- PlanningSessionManager.ResumeAsync (84b0ba8)
- PlanningSessionManager.StartAsync (b6bec1e)
- friendly error when deleting task with children (0e116be)
- unfinished planning session dialog (47b4974)
- draft and planning badge styles (506caa2)
- planning entries in task context menu (388a8c1)
- TaskRowView hierarchy indentation, chevron, badges, draft italic (42b208f)
- planning commands and expand/collapse in TasksIslandViewModel (309f84b)
- WorkerClient planning-session methods (0060840)
- TaskRowViewModel gains planning hierarchy flags (229d4bb)
- hook TryCompleteParentAsync after MarkDone/MarkFailed (d4a4642)
- TaskRepository.TryCompleteParentAsync (b7464c9)
- TaskRepository.DiscardPlanningAsync (524aaf8)
- TaskRepository.FinalizePlanningAsync (a9e7479)
- TaskRepository.FindByPlanningTokenAsync (2e80cc6)
- TaskRepository.UpdatePlanningSessionIdAsync (d099138)
- TaskRepository.SetPlanningStartedAsync (2278d97)
- TaskRepository.CreateChildAsync (74255dd)
- TaskRepository.GetChildrenAsync (b466246)
- migration AddPlanningSupport (b3eb39a)
- configure planning columns and self-ref FK with Restrict (253e6f0)
- add planning columns and self-ref navigations to TaskEntity (042a1b4)
- add Planning, Planned, Draft task statuses (7a20534)
- move list-settings access from lists pane to tasks header (ee2cbc9)
- add update banner and Help menu to MainWindow (00c6217)
- wire update-check state and commands into shell VM (bbe7d73)
- register UpdateCheckService and InstallerLocator in DI (0934b29)
- show worker log line in footer (b28d8f2)
- add worker log state and 30s timer to shell VM (ec4ec44)
- add InstallerLocator (ee09706)
- add UpdateCheckService (c06d1d6)
- add WorkerLogLevelToBrushConverter with tests (f906e70)
- self-update pre-flight before wizard (caf900b)
- subscribe to WorkerLog SignalR event (e80e3fc)
- emit WorkerLog for merge, discard, reset (e805655)
- emit WorkerLog events from TaskRunner (ea4d2d7)
- add SelfUpdater.DownloadAndVerifyAsync (98c188a)
- add SelfUpdater.HandleReplaceSelfAsync (0c3dcb0)
- add SelfUpdater.DecideUpdateAsync (e017d66)
- add SelfUpdater installer-asset matching (ba0b38b)
- add VersionComparer (7c0f8d8)
- add WorkerLog SignalR event (0a7fcae)
- add WorkerLogLevel enum (80f6669)
- add empty ClaudeDo.Releases library (86012e0)
- replay persisted task log when selecting a task (c8c8bb4)
- add queueing and scheduling from task row context menu (6f725d1)
- use ClaudeTask icon for window and taskbar (9952ff9)
- show version info and offer worker restart in settings (4a6d96b)
- record data directory in install manifest (2690332)
- harden database init and service setup steps (31218fc)
- add Restore default agents button to Settings modal (e70ae7f)
- add RestoreDefaultAgentsAsync to WorkerClient (1830273)
- expose RestoreDefaultAgents hub method (1a10e6f)
- seed default agents on startup (df57c2b)
- add DefaultAgentSeeder for first-launch agent seeding (990be09)
- add bundled default agent definitions (ff3de1d)
- always-visible Steps section at top of DetailsIsland with add-step input (b0b15e4)
- per-task agent settings in DetailsIsland (bba5778)
- open ListSettingsModal via context menu and gear button (5784dbe)
- add ListSettingsModalView (5348220)
- add ListSettingsModalViewModel (cd0b95e)
- WorkerClient supports list/task agent settings + ListUpdated event (fc1cfe5)
- add hub methods for list and task agent settings (7c31216)
- add TaskRepository.UpdateAgentSettingsAsync (480eb08)
- add ListRepository.DeleteConfigAsync (1b94fa5)
- show status messages and real diff-stats in DiffModal (3142057)
- add Merge button to DiffModal (1bc7fcc)
- add Merge command to DiffModal (c911717)
- attach MergeModal to DetailsIsland (949911f)
- wire DetailsIsland ApproveMerge through MergeModal (f3a58a6)
- add MergeModalView (e11b019)
- add MergeModalViewModel (3d0cc4f)
- add MergeTaskAsync and GetMergeTargetsAsync to WorkerClient (4585b20)
- expose MergeTask and GetMergeTargets on WorkerHub (c53b587)
- implement TaskMergeService happy path (3331c24)
- scaffold TaskMergeService with pre-flight checks (1c20d8f)
- add ListConflictedFilesAsync (77a1460)
- add MergeAbortAsync (21a1870)
- add MergeNoFfAsync returning (exitCode, stderr) (3ebbdb3)
- add IsMidMergeAsync (535d0c5)
- add ListLocalBranchesAsync (2d807aa)
- add GetCurrentBranchAsync (93ee7b7)
- add Continue and Reset buttons to agent strip (2ce6b7b)
- add Continue and Reset commands to DetailsIslandViewModel (b03e858)
- add ContinueTaskAsync and ResetTaskAsync to WorkerClient (2278b51)
- expose ResetTask hub method (219a231)
- add TaskResetService for discard + reset flow (74eb36d)
- add TaskRepository.ResetToManualAsync (202236a)
- add WorktreeManager.DiscardAsync for task reset (44203f3)
- add settings modal and wire to worker hub (e6b3762)
- extend ClaudeArgsBuilder with MaxTurns and PermissionMode (fca5d57)
- add WorktreeMaintenanceService for idle-worktree cleanup (cfb9ca1)
- add AppSettings entity, migration, and repository (62a1121)
- render user tool_result blocks as one-line summaries (374e811)
- render assistant tool_use blocks with per-tool args (3a67fe8)
- render assistant text blocks, skip thinking (dc6e3fe)
- format system init message in StreamLineFormatter (b525498)
- keyboard shortcuts (/ Ctrl+N Space Esc) (6dade01)
- pulse, hover, modal, and row-add animations (47e8e1f)
- worktree modal with tree view and M/A badges (abd7733)
- diff modal with file sidebar and tinted hunks (4d68543)
- tasks island with rows, chips, add-task, selection (f94bb35)
- details island with agent strip, terminal, subtasks, notes (4f41b08)
- DetailsIslandViewModel with agent state and log (fcf53ab)
- TasksIslandViewModel with smart/virtual/user filtering (0034acc)
- Lists island view with search and nav items (f167120)
- TaskRowViewModel with status chip mapping (dc1b648)
- ListsIslandViewModel with smart/virtual/user lists (06cc141)
- chromeless three-island shell (05404f4)
- scaffold islands shell and child VMs (8909119)
- merge Tokens and IslandStyles into App (55917c9)
- embed Inter Tight and JetBrains Mono fonts (1893576)
- add design Tokens resource dictionary (92a6e06)
- add island control styles (579b527)
- seed default Lists (My Day, Important, Planned) (bd8a4d0)
- migration for IsStarred/IsMyDay/Notes columns (928dde1)
- add IsStarred, IsMyDay, Notes to TaskEntity (a1190a3)
### Fixes
- cap run-log read size and harden run-history tests (fec2fe2)
- reuse shared hub fake and guard blank list name (ac2f1d8)
- apply blue PLANNED badge for finalized planning, drop dead converter statics (7a88e8a)
- strip prerelease and build metadata before version compare (b84716f)
- narrow delete-list FK catch to SqliteException (6e3947c)
- narrow RepoScanner catch to filesystem exceptions (4877c11)
- widen About modal so folder Open buttons are not clipped (c1c7862)
- restore Ui.Tests build by implementing ListUpdatedEvent in fakes (12668f6)
- dispatch WorkerLog events to UI thread (7d61d38)
- wire details-island buttons and drop dead handlers (e55367a)
- default-expand diff tree; reliable row-click toggle (7e3ae70)
- toggle expand on full folder row click (232d7cb)
- use BorderOnly chrome; color diff +/- lines (6c8048d)
- make overview modal resizable; add diff content pane (6670771)
- resizable modal, drop branch column, show committed diff (bc15c16)
- preserve status message after cleanup; English label (8f4e37e)
- restore green test suite across all projects (8eafa71)
- attach agent tag to chained children for queue pickup (721c36a)
- emit PlanningMergeAborted (not Conflict) on non-conflict merge failures (ce23f64)
- prevent PlanningMergeOrchestrator double-drain race and orphaned state (ef070dd)
- reorder PlanningAggregator checkout/delete and kill git on cancel (9d04d1d)
- align virtual list semantics and complete planning roll-up coverage (6bdfa73)
- wrap MergeAbortAsync in AbortMergeAsync for consistent error handling (ada4d9f)
- planning parents roll up child status; children stay nested until parent Done (6d460ea)
- tighten ContinueMergeAsync guards and commit error handling (63759ee)
- derive planning MCP URL from configured SignalRPort (e62485d)
- register TaskRepository in DI and guard null WorkingDir (c048264)
- planning launcher — avoid cmd shell to prevent prompt injection (9e09ae6)
- enable foreign_keys pragma in MigrateAndConfigure (7821106)
- select task on left-click even when reorder is disabled (1344beb)
- session terminal scrolls to end after layout so last line is fully visible (7de5510)
- pin AgentStrip above metadata footer, terminal sits above it (5e54275)
- session terminal auto-sizes to output, caps at 420px before scrolling (6ac8823)
- move agent-settings expander out of capped scroller so it expands properly (839f862)
- use PlaceholderText instead of obsolete Watermark in ListSettingsModalView (2901a76)
- use UTF-8 encoding for git process stdio (07dee31)
- disable Merge button after worktree is no longer Active (4debd5c)
- return Blocked when MergeAbortAsync fails to avoid stuck repo (1495c63)
- honour targetBranch in MergeAsync by checking out before merge (953d931)
- correct Reset button tooltip wording (58c8210)
- early-return in ResetAsync when ConfirmAsync is unwired (f90d3d8)
- prefix broadcast lines with [stdout] so UI parser routes them (4283c67)
- truncate WebFetch URL in tool_use arg (ec679e4)
- filled window icons, boxed task rows, proper explorer button (e19a9d3)
- NAVIGATOR eyebrow — drop broken converter binding (42fb7ce)
- wire delete confirm, close-details, uppercase eyebrow, explorer button (5acc896)
- drop icon-btn sizing from AgentStrip text buttons (27c6a4b)
- use Tag-attribute selectors for terminal log colors (2d1a488)
- guard Bind/LoadForList against interleaved DbContext awaits (62aac7e)
- wire modal delegates from DetailsIslandView owner (279f2c7)
- remove stale brush overrides in App.axaml (9514651)
- restore ViewModels using for IslandsShellViewModel (eee98b7)
### Refactoring
- merge TaskRunner failure handlers and reuse NullIfBlank (1856943)
- fold single-consumer helper types into their owners (ce9fadc)
- remove dead PlanningMergeEvents records and unused RunNowRequestedEvent (25ee623)
- extract interfaces to Interfaces folders and consolidate filters (41da124)
- consolidate commit types into CommitTypeRegistry (5da69ee)
- consolidate permission modes into PermissionModeRegistry (5308ba3)
- consolidate model aliases into ModelRegistry (a62ef24)
- remove tag entity and all references (623ebf1)
- dequeue orphans instead of promoting, restore lost lineage (0d55002)
- consolidate task list filters into single strategy registry (e68bb73)
- retire legacy TaskStatus values and backfill existing rows (dc3fc44)
- extract OverrideSlotService and reorganize Worker/Services into domain folders (ff7c239)
- split queue waker and picker, auto-wake on enqueue (064a903)
- introduce TaskStateService and route mutations through it (8823265)
- use --permission-mode auto instead of --dangerously-skip-permissions (b2eb5fc)
- test planning detail pane via real ViewModel and restore merge-all IsEnabled binding (1aead9d)
- switch MCP config to env-var token expansion (975e1ce)
- add worktree path and token file helpers (1d61df8)
- inject GitService and WorkerConfig into PlanningSessionManager (1370bf3)
- drop McpConfigPath from PlanningSessionFiles (f2db5f4)
- extend planning contexts with token and worktree (fd2ac48)
- use shared VersionComparer in InstallModeDetector (5b4cdd3)
- move release-API + checksum types to ClaudeDo.Releases (46e01ae)
- redesign list settings and merge modals with custom chrome (5ced1b9)
- single scrollable DetailsIsland body with agent-settings gear flyout, remove Notes (c599fdc)
- skeleton dispatch for StreamLineFormatter rewrite (668087c)
- centralize list seeding in MigrateAndConfigure, add default-value test (9a05907)
### Documentation
- sync CLAUDE.md files with current architecture (cfc4511)
- correct external MCP tool inventory, drop removed tag tools (32daa4a)
- clarify SetTaskConfig null-clears-override wording (f3f8af4)
- add external MCP UI-parity spec and plan (99dc084)
- clarify repo-import checkbox default intent (2f7f00d)
- add repo import list helper implementation plan (5b15e30)
- document RepoImportModalView (e5bce07)
- add repo import list helper design spec (7869c2a)
- add planning draft/planned queue gate design spec (09a930e)
- add worktree overview modal spec and plan (b944597)
- regenerate against current code state (a6608bf)
- add design + plan for tabbed settings + Prime Claude (2ff0971)
- add session prompts for worker state consolidation slices 2-6 (cf7a6e4)
- add worker state and queue consolidation spec (43af17e)
- add external MCP CRUD extensions spec and plan (10b2ca8)
- document new external MCP tools (1b9f2d4)
- add planning UX spec/plan and prompts/mailbox proposals (615c1da)
- add spec and plan for planning merge-all feature (8afbf20)
- add worktree-isolated MCP session design and plan (4de2dea)
- add planning-session manual verification checklist (450e685)
- add planning sessions implementation plans A, B, C (43d517d)
- add planning sessions design (8891d48)
- add self-update manual verification checklist (a41e5b5)
- add worker-log footer implementation plan (ea76945)
- add worker-log footer implementation plan (41e0bea)
- add worker-log footer design spec (da19eb8)
- add implementation plan (0d37473)
- add design spec for app + installer self-update (6a4bf67)
- add default-agents plan and design spec (a135485)
- refresh CLAUDE.md files for agent settings UI (e74e7ee)
- agent settings UI implementation plan (02464b7)
- agent settings per list and per task UI reimplementation (68f461d)
- add 2026-04-21 open-items consolidation (cb43bcd)
- clarify merged-with-cleanup-warning result shape (32ef1b3)
- add worktree merge implementation plan (0885518)
- add worktree merge design spec (944d3bd)
- note ResetTask hub method and TaskResetService (fb89e02)
- add implementation plan for continue and reset buttons (133774c)
- add spec for continue and reset buttons on failed tasks (a3bb557)
- add UI-rewrite notes, plans, and stream-formatter spec (23f8fdd)
- add design spec (b474113)
## v1.2.0 — 2026-04-17
### Features
- add subtask tree view with expand/collapse in task list (32bb528)
### Fixes
- expand ~ in UiDbPath (2a8cd97)
- init editor TCS before dialog can complete (09e8b1f)
- reset stale worktree state on TaskDetail reload (92d8d90)
- capture CurrentListId before await in AddTask (aa1008d)
- make user-data deletion on uninstall opt-in (5f3d41e)
- rollback-safe extract with .bak stash (7d48f34)
- move service start out of RegisterServiceStep (51a1bbe)
- escape newline/tab in CLI args (ad7c9fa)
- guard against same task in queue and override slot (11a4376)
- reject CurrentUser service account without password (f10ad69)
- swallow DB errors in TaskListViewModel.OnTaskUpdated (dc4571a)
- emit RunCreated after run row exists (4fb6ba6)
- resolve critical bugs and improve reliability across worker, data, UI (3423919)
### Documentation
- add subtask tree view design spec (4f25c3d)
## v1.1.0 — 2026-04-16
### Features
- wire EF Core into DI and update all consumers to IDbContextFactory (36484ed)
- rewrite all repositories to use EF Core ClaudeDoDbContext (34ca1b0)
- add ClaudeDoDbContext with Fluent API configurations (51a5dcb)
- add navigation properties to all entity models (f8f1386)
### Fixes
- address code review findings (611454d)
### Refactoring
- switch InitDatabaseStep to EF Core migrations (7d0ca45)
### Documentation
- update CLAUDE.md files for EF Core migration (8d61b05)
- add EF Core migration implementation plan (9236ca6)
- add EF Core migration design spec (9e1f137)
## v1.0.0 — 2026-04-15
### Features
- add app and installer icons (3b1f148)
- agent config inline in detail panel, file picker, subtask UI (9a407bd)
- add subtasks table, repository and prompt integration (8c051d8)
- remove MaxWidth on main columns to use full window width (8577c55)
- mode-aware wizard page list + Update-mode step pipeline (b5455a1)
- Config view — Save/Repair/Uninstall commands + footer buttons (2898bec)
- add UninstallRunner (service + shortcuts + dirs) (ac38ea8)
- rewrite WelcomePage for download-mode + update heading (da1fe21)
- async mode detection + mode-aware DI wiring (01c29bb)
- add WriteInstallManifestStep (5482518)
- add DownloadAndExtractStep with SHA256 verify (c1e3301)
- add Stop/StartServiceStep sc.exe wrappers (d87de15)
- replace sync ModeDetector with async InstallModeDetector (97fb215)
- add IReleaseClient + Gitea ReleaseClient (5603fd4)
- add ChecksumVerifier (SHA256 + checksums.txt parser) (d0c0e2c)
- add InstallManifest + json-backed store (921e626)
- add Gitea Actions release workflow (aea0909)
- add WPF installer/configurator project (78831b2)
- add config override fields to TaskEditorView (f8be2c1)
- complete Batch 2 — LiveText display, start feedback, modal theming, ListEditor config (699fe8a)
- replace LiveLines with formatted LiveText, add log reload and start feedback (0764bb3)
- add starting state feedback to task list (503fd69)
- add StreamLineFormatter for NDJSON stream parsing (365ecba)
- default to claude-sonnet-4-6 when no model configured (945a1ee)
- add RunNowRequestedEvent and GetAgentsAsync to WorkerClient (026df8d)
- add ContinueTask routing to QueueService (adc5a16)
- add ContinueTask, GetAgents, RefreshAgents hub methods and RunCreated broadcast (6cb8012)
- add AgentFileService for filesystem agent management (8825351)
- extend RunResult with structured output, session ID, and token metrics (54c4d3c)
- extend TaskRepository with model, system_prompt, agent_path columns (f57cdb7)
- add StreamAnalyzer for rich NDJSON stream parsing (8b342bc)
- add ClaudeArgsBuilder for dynamic CLI argument construction (dab461c)
- add GetConfigAsync and SetConfigAsync to ListRepository (5232d5f)
- add TaskRunRepository with CRUD and query methods (19a2104)
- add ListConfigEntity, TaskRunEntity, AgentInfo models and task config fields (02aaa9d)
- add list_config, task_runs tables and task config columns (36ae653)
- add global keyboard shortcuts (Ctrl+N, Ctrl+L, Ctrl+R, Ctrl+Shift+N) (ff5e56a)
- add inline add handlers, checkbox click, and task keyboard shortcuts (2dcfc7e)
- add auto-save LostFocus handlers and tag input KeyDown (a44c104)
- wire TaskDetail changes back to task list refresh (f51278e)
- make TaskDetailViewModel editable with auto-save and tag CRUD (28a0d9b)
- add inline task creation, toggle-done, and list name to TaskListViewModel (a4da2e2)
- add ToggleDone command and checkbox state to TaskItemViewModel (0796b3c)
- add colored dot brush to ListItemViewModel (3c52e9c)
- add CheckboxBorderConverter for task status circles (a548d41)
- add context menus for lists and tasks (3653dca)
- open editor on double-click for lists and tasks (db5a447)
- wire avalonia desktop ui to data and worker (48e4aab)
- add git worktree support and conventional commits (01235d9)
- add claude-cli runner, queue service, and hub api (e5038d7)
- add repositories, stale-task recovery, and test foundation (9f51ff0)
- add db schema init and signalr hub skeleton (f81ef02)
### Fixes
- prevent async void races and leak-on-exit (2b3fe02)
- address concurrency, cancellation, and resource issues (d3b85f2)
- wait for prior service registration to clear before create (fc9029d)
- publish framework-dependent single-file (1c764da)
- disable single-file compression to prevent WPF startup AV (cfec329)
- service hosting, dark theme, uninstall polish (f599f8d)
- set EnableWindowsTargeting so Linux Gitea runners can publish (9b928c6)
- UninstallRunner abort-on-stop-fail + path guard + partial-failure reporting (5d42438)
- null-defensive WelcomePage heading + guard unreachable modes (8d2f7e9)
- fall back to Config on detection timeout when install.json exists (5e432a4)
- wrap WriteInstallManifestStep I/O in try/catch like sibling steps (12e5327)
- harden DownloadAndExtractStep per review (ea32a74)
- check exit code (not stdout) for ERROR_SERVICE_ALREADY_RUNNING (5b4af29)
- propagate cancellation + defensive asset parsing in ReleaseClient (83d7058)
- fix live output visibility and editor dialog graying out (2a1f26d)
- address code review findings (7363e48)
- allow RunNow for any non-running task, not just queued (95c8cc8)
- update QueueServiceTests for new TaskRunner constructor signature (26c2445)
- replace deprecated Watermark with PlaceholderText (5f51fe9)
- register TagRepository in TaskDetailViewModel constructor (5b6c095)
- re-evaluate RunNow CanExecute when worker connection changes (473e0f7)
- make list and task rows fully hit-testable for clicks (981b8e4)
- context menu operates on right-clicked item and gates new-task on list selection (5d5a583)
- harden worker auto-reconnect lifecycle (fdf357b)
- cancel retry loop before disposing worker connection (36ef624)
- auto-reconnect worker connection with retry backoff (c6522cf)
### Refactoring
- replace SourceDirectory with Mode/Version fields in InstallContext (4fab048)
- remove source-build steps (replaced by DownloadAndExtractStep) (0989176)
- remove MessageParser (replaced by StreamAnalyzer) (c1c4c75)
- rewrite TaskRunner with config resolution, retry, and continue support (76473dd)
- simplify ClaudeProcess to accept pre-built args and use StreamAnalyzer (1cdaaf9)
- harden context menu event handling and simplify bindings (7838f08)
- harden double-click edit handlers (6727cc4)
### Documentation
- add download-mode implementation plan (c0bd465)
- finalize decisions — self-contained, auto-check, full uninstall (0498fba)
- pin release target to releases/ClaudeDo (43a10cf)
- add download-mode + Gitea Releases design spec (bd7d594)
- add implementation plan for UI fixes (a6fe91d)
- add design spec for post-integration UI fixes (fb3c96c)
- update CLAUDE.md with CLI modernization changes (03728c8)
- add UX redesign implementation plan (16 tasks) (9f61cd1)
- add UX redesign spec (Microsoft To Do style) (0e41c37)

View File

@@ -10,11 +10,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- **ClaudeDo.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm) - **ClaudeDo.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm)
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService - **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner - **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
- **ClaudeDo.Localization** — `locales/en.json` + `locales/de.json` and the lookup service - **ClaudeDo.Worker.Tests** — xUnit integration tests with real SQLite and real git
- **ClaudeDo.Installer** — WPF (`UseWPF`) setup app; install/update/uninstall step pipeline
- **tests/** — six xUnit projects (Worker, Data, Ui, Localization, Installer, Releases); Worker.Tests run real SQLite and real git
Each project has its own `CLAUDE.md` — those are the living per-project docs.
## Tech Stack ## Tech Stack
@@ -39,7 +35,7 @@ Each project has its own `CLAUDE.md` — those are the living per-project docs.
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data) - EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker) - `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder - Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
- Task status flow: Idle | Queued -> Running -> 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. - 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).
- Worktree state flow: Active -> Merged | Discarded | Kept - Worktree state flow: Active -> Merged | Discarded | Kept
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed - The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged) - Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
@@ -79,8 +75,6 @@ dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
## Docs ## Docs
- `docs/open.md`open verification items and remaining code TODOs (the only doc kept current besides the CLAUDE.md files) - `docs/plan.md`full architecture and design spec
- `docs/plan.md`original design spec (historical; tag-queue/schema.sql parts are outdated) - `docs/open.md`verification checklist and improvement backlog
- `docs/improvement-plan.md`improvement snapshot from 2026-04-13 (historical) - `docs/improvement-plan.md`prioritized improvement items
- `docs/prompts-inventory.md`, `docs/mailbox-proposal.md` — reference material (mailbox integration is parked)
- `CHANGELOG.md` — Keep a Changelog format, maintained on release

View File

@@ -7,6 +7,7 @@
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" /> <Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" /> <Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
<Project Path="src/ClaudeDo.Localization/ClaudeDo.Localization.csproj" /> <Project Path="src/ClaudeDo.Localization/ClaudeDo.Localization.csproj" />
<Project Path="src/ClaudeDo.Logging/ClaudeDo.Logging.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj" /> <Project Path="tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj" />

View File

@@ -1,7 +1,5 @@
# ClaudeDo — Improvement Plan (Session 2026-04-13) # ClaudeDo — Improvement Plan (Session 2026-04-13)
> **Hinweis (2026-06-09):** Historischer Snapshot — bewusst nicht nachgepflegt. U.a. erledigt/überholt: IP-1 (Auto-Reconnect ist implementiert), `schema.sql` → EF-Core-Migrations, `StatusBarViewModel` existiert nicht mehr (Connection-State lebt in `IslandsShellViewModel`), Tags sind Junction-Tabellen statt JSON-Spalten. Offene Punkte stehen in `open.md`.
Erfasst während manuellem Walkthrough der App. Priorisiert nach Schmerz/Aufwand. Erfasst während manuellem Walkthrough der App. Priorisiert nach Schmerz/Aufwand.
--- ---

View File

@@ -1,173 +0,0 @@
# 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.

View File

@@ -1,6 +1,6 @@
# ClaudeDo — Offene Punkte # ClaudeDo — Offene Punkte
Stand: 2026-06-10. **Nur noch offene Punkte.** Was erledigt ist, steht in den Commits und im Code — nicht hier. Stand: 2026-06-04. **Nur noch offene Punkte.** Was erledigt ist, steht in den Commits und im Code — nicht hier.
--- ---
@@ -13,45 +13,11 @@ Kein Code-Aufwand, nur Durchspielen mit explizit notiertem Pass-Kriterium. Der G
- No-Changes-Run → `status='Done'`, `head_commit IS NULL`, `diff_stat IS NULL`. - 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. - 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"). - **Feature-Walkthroughs:** Planning-Session-Flow (Draft→Finalize→Chain), Prime/Daily-Prep-Trigger, Weekly-Report-Generierung, Self-Update (Banner → Update → „up to date").
- **UI-Sichtprüfung (neu, 2026-06-09):** Diff-Viewer (Dateiliste, Added/Deleted/Renamed/Binary-Erkennung, Commit-Range-Diff nach Merge) und das „children need attention"-Band auf dem Session-Tab des Parents.
- **UI-Sichtprüfung (neu, 2026-06-10, nach Refactoring-Merges):** Detail-Insel komplett durchklicken (Output/Git/Session-Tabs, Merge-Sektion, Agent-Settings-Overrides, Prep-Panel) — `DetailsIslandViewModel` wurde in Sektions-VMs aufgeteilt, Bindings angepasst. Außerdem: DiffModal-Fehler-State „Diff nicht mehr verfügbar" (Commit-Range ohne aufgezeichnete Commits) und der In-App-Konflikt-Resolver (Hub-Methoden umbenannt).
- **UI-Sichtprüfung (neu, 2026-06-19, Rider-Style 3-Pane Merge-Editor):** Echten Konflikt auslösen (Single-Task-Approve mit Konflikt **und** Planning-Unit-Merge) und prüfen: drei Panes (Ours read-only | Result editierbar | Theirs read-only), Konfliktblöcke rot / aufgelöst grün in allen Panes, Inline-Accept ``/`` in den Zwischen-Guttern landen die jeweilige Seite im Result, nur Konfliktregionen im Result editierbar (Stable read-only), synchrones vertikales Scrollen, File-Switcher bei mehreren Dateien, `M conflicts · K resolved`-Readout, Continue erst bei allen Konflikten gelöst, Binär-Guard. **Bekannte Kanten:** (1) Konflikt mit leerer Ours-Seite → Result-Region ist null-lang (Gutter via 1-Zeichen-Probe positioniert, Accept funktioniert; nur Hand-Tippen in die leere Region ist fummelig). (2) Gutter-Y nutzt `TranslatePoint` vom Result-`TextView` — bei sehr hohen Fenstern / großen Scrollständen die Ausrichtung gegenprüfen. (3) Blöcke richten sich nur über Stable-Text aus; nach einem Konflikt mit unterschiedlicher Zeilenzahl je Seite driften nachfolgende Blöcke vertikal (aligned/virtual-space Scroll ist bewusst zurückgestellt).
- **Worker-Autostart am Gerät:** Logoff/Logon-Autostart, Update-Pfad, Uninstall entfernt die Startup-`.lnk`. - **Worker-Autostart am Gerät:** Logoff/Logon-Autostart, Update-Pfad, Uninstall entfernt die Startup-`.lnk`.
- **In-App Interactive Sessions (neu, 2026-06-26):** ersetzt den externen `wt`-„Run interactively"-Launch durch einen In-App-Streaming-Chat (`StreamingClaudeSession`, `claude --input-format stream-json`). Real-CLI-Smoke (kein xUnit, kein Claude in Tests):
- Task rechtsklick → „Run interactively" startet **keinen** Terminal mehr; der Stream erscheint im Detail-Output-Tab des (selektierten) Tasks und als Monitor in Mission Control.
- Composer: Nachricht tippen + Enter/Send → erscheint sofort als `log-user`-Zeile in **Akzentfarbe** (via `LogKindForegroundConverter`, lokale Bindung schlägt den dim Style), Claude antwortet im selben Prozess.
- **Senden während Claude arbeitet = Queue (Default):** die Nachricht wird gepuffert und beim `result` des laufenden Turns abgeschickt (kein Interrupt). Mehrere Queue-Nachrichten FIFO, eine pro Turn. Gequeute Nachrichten erscheinen in einem **Pending-Streifen über der Eingabezeile** (⧗-Liste, via `InteractiveQueueChanged`); eine Nachricht landet erst im Transkript (`log-user`-Zeile via `InteractiveMessageSent`), wenn sie tatsächlich an Claude zugestellt wird. Der seeded Erst-Prompt erscheint als erste User-Zeile. Jede gequeute Zeile hat ein **✕ zum Entfernen** (`RemoveQueuedInteractiveMessage`, by-text first-match; Worker re-broadcastet die Queue).
- **Interrupt opt-in:** der kleine ■-Stop-Button neben Send unterbricht den laufenden Turn (`control_request`/`interrupt`, verifiziert mit CLI 2.1.191; Abbruch-`result` = `error_during_execution`, als Turn-Ende behandelt) — danach flusht die ggf. gequeute Nachricht im selben Prozess mit erhaltenem Kontext. Stop-Button ist immer sichtbar solange live (Interrupt im Idle ist ein No-op; Turn-in-flight wird nicht in die UI gebroadcastet).
- Session-Ende: Prozess-Exit/Stop → `InteractiveSessionEnded`, Composer verschwindet, Monitor wird „done".
- **Sicht-Konsistenz:** Mission-Control-Composer (SessionTerminalView-Bottom-Row mit Send-Button) vs. Detail-Composer (WorkConsole-Shell-Prompt ` … [Send]`) sehen unterschiedlich aus — ggf. angleichen.
- **Drag-and-drop file attachments on the detail pane:** verify the "Drop to attach" hover overlay, drop round-trip (file appears in the list), "Add file…" picker, remove button, and that files land under `~/.todo-app/attachments/<taskId>/`. Also verify the MCP `AddTaskAttachment`/`ListTaskAttachments`/`RemoveTaskAttachment` tools and that a Running task refuses add/remove. (Manual; can't be unit-tested.)
## Offene Code-Punkte ## Offene Code-Punkte
- **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. - **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.
- **`AgentMcpTools` liegt in `LifecycleMcpTools.cs`** — beim Suchen irreführend; in eigene Datei verschieben. Ein-Minuten-Fix, lohnt keinen Agent-Lauf — beim nächsten Worker-Touch mitnehmen.
## Nachklapp Refactoring-/Bug-Runde (2026-06-09/10)
Alle 9 Review-Tasks (5 Refactorings, 4 Bugfixes) sind umgesetzt und gemerged; Details in den Commits. Offen geblieben:
- **`DetailsIslandViewModel` ist nach dem Split noch 1258 Zeilen** (Ziel war ~800) — die drei Sektions-VMs (AgentSettings, Merge, Prep) sind extrahiert, weitere Extraktion (z.B. ChildOutcomes/Subtasks-Sektion) lohnt erst, wenn die Datei wieder wächst.
- **Bewusst zurückgestellt:** WorkerHub-Split nach Concern (~60 Methoden in einer Hub-Klasse). Die Interface-Parität löst das akute Testbarkeits-Problem; ein Hub-Split ist eine größere Architekturentscheidung → erst besprechen.
- **Lessons learned:** Der `StartRunningAsync`-Guard-Task hat isoliert grün getestet, aber den Queue-Pfad gebrochen (Picker claimt vor dem Dispatch) — Integrationsfix `74ca2e0`. Bei parallelen Tasks, die denselben Pfad berühren, nach JEDEM Merge-Schwung die volle Suite auf main fahren.
## Bug-Befunde (Korrektheits-Review 2026-06-09)
**Plausibel, noch nicht einzeln verifiziert (bei Gelegenheit prüfen):**
- Cancel eines `WaitingForChildren`-Parents kaskadiert nicht auf laufende/queued Kinder (verwaiste Worktree-Commits).
- Ketten-Kaskade stoppt an einem `Idle`-Mittelglied (`OnChildFinishedAsync` prüft `CancelAsync`-Ergebnis nicht) → Rest bleibt `Queued+blocked`.
- Delete des *letzten* nicht-terminalen Kindes triggert kein `TryAdvanceParentAsync` → Parent kann in `WaitingForChildren` hängen (FK `SET NULL` rettet nur die Blocked-Kette).
- `ContinueMergeAsync` staged per `git add -A` vor dem Konflikt-Check (Marker im Index, Abort danach ggf. unsauber).
- `HasChangesAsync` zählt untracked Files → blockiert Merges unnötig (`--untracked-files=no`).
- `UnifiedDiffParser`: Pfade mit Leerzeichen / git-gequotete Pfade aus `diff --git` falsch geparst.
- Kleinkram: MergePreview-Race bei schnellem Target-Wechsel, CTS-Dispose-Leak in Debounce-Saves, `Environment.CurrentDirectory`-Fallback im Konflikt-Dialog, Doppel-Continue-Fenster im Orchestrator.
**Geprüft und verworfen (keine Bugs):** ReviewFeedback-„Endlosschleife" (Fallback existiert), Cross-Thread-Crashes im DetailsIslandViewModel (Dispatcher-Marshalling im WorkerClient), Chain-Wedge nach Child-Delete (FK `ON DELETE SET NULL`), `\ No newline`-Parsing.
--- ---

View File

@@ -1,7 +1,5 @@
# ToDo-App mit autonomem Agent-Worker — Design # ToDo-App mit autonomem Agent-Worker — Design
> **Hinweis (2026-06-09):** Historisches Design-Dokument vom Projektstart — bewusst nicht nachgepflegt. Überholt sind insbesondere: die Tag-basierte Queue (entfernt; der Picker nutzt `Status=Queued` + `BlockedByTaskId IS NULL`), `schema.sql` (Schema läuft über EF-Core-Migrations) und das Projektlayout (inzwischen sechs Testprojekte). Lebende Doku sind die `CLAUDE.md`-Dateien pro Projekt.
## Context ## Context
Ziel: eine persönliche ToDo-App als Desktop-Anwendung, in der mehrere Listen verwaltet werden können. Ein Teil der Tasks soll autonom von Claude abgearbeitet werden (z.B. Recherche, Code-Aufgaben, Notizen-Verarbeitung). Die Autonomie läuft in einem getrennten Hintergrund-Prozess, damit die UI davon entkoppelt bleibt. Ziel: eine persönliche ToDo-App als Desktop-Anwendung, in der mehrere Listen verwaltet werden können. Ein Teil der Tasks soll autonom von Claude abgearbeitet werden (z.B. Recherche, Code-Aufgaben, Notizen-Verarbeitung). Die Autonomie läuft in einem getrennten Hintergrund-Prozess, damit die UI davon entkoppelt bleibt.

View File

@@ -1,994 +0,0 @@
# 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.

View File

@@ -1,801 +0,0 @@
# 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 14 are backend and largely sequential (4 depends on 3). Tasks 1 and 2 are independent and could be done first in either order.
- Tasks 56 (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.

View File

@@ -1,33 +0,0 @@
# 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

View File

@@ -1,432 +0,0 @@
# 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 387390), 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 255313 — 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 &amp; 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
23), and both fakes (0.2). The seam `RequestConflictResolution` is
`Func<string,string,Task>?` everywhere (A.1 Steps 1, 35). 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.

View File

@@ -1,139 +0,0 @@
# 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.
```

View File

@@ -1,920 +0,0 @@
# 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.

View File

@@ -1,837 +0,0 @@
# 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 14), skip-and-continue + conflict collection (Task 2), single target picker (Tasks 34), Resolve → `RequestConflictResolution(taskId, targetBranch)` seam left unwired (Tasks 34), `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. ✔

View File

@@ -1,522 +0,0 @@
# 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 &amp; 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="&#x276F;"
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).

View File

@@ -1,55 +0,0 @@
# 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 34; 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.

View File

@@ -1,90 +0,0 @@
# 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.

View File

@@ -1,72 +0,0 @@
# 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.

View File

@@ -1,104 +0,0 @@
# 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 01 are detailed here; 25 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 (B3B5). Lowmedium.
**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.

View File

@@ -1,92 +0,0 @@
# 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.

View File

@@ -1,131 +0,0 @@
# 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.

View File

@@ -1,111 +0,0 @@
# 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.

View File

@@ -1,32 +0,0 @@
# 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.

View File

@@ -1,56 +0,0 @@
# 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.

View File

@@ -1,98 +0,0 @@
# 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.

View File

@@ -1,101 +0,0 @@
# 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.

View File

@@ -1,173 +0,0 @@
# 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).

View File

@@ -1,200 +0,0 @@
# 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
```

View File

@@ -1,127 +0,0 @@
# 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).

View File

@@ -1,200 +0,0 @@
# 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.

View File

@@ -1,197 +0,0 @@
# 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).

View File

@@ -1,99 +0,0 @@
# 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.

View File

@@ -1,80 +0,0 @@
# 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).

View File

@@ -1,148 +0,0 @@
# 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.

View File

@@ -1,142 +0,0 @@
# 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.)

View File

@@ -1,91 +0,0 @@
# 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 23 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) | 34 | 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 | 23 | 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 (B3B5).** 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 01.
- **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 (25) flag a visual-verification gap for Mika to confirm in the running app.

View File

@@ -1,132 +0,0 @@
# 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.

View File

@@ -1,49 +0,0 @@
# 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.

View File

@@ -1,102 +0,0 @@
# 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).

View File

@@ -1,144 +0,0 @@
# 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, 34→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.

View File

@@ -1,147 +0,0 @@
# 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.

View File

@@ -21,7 +21,6 @@
<converters:DotBrushConverter x:Key="DotBrush"/> <converters:DotBrushConverter x:Key="DotBrush"/>
<converters:BoolToItalicConverter x:Key="BoolToItalic"/> <converters:BoolToItalicConverter x:Key="BoolToItalic"/>
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/> <converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
<converters:LogKindForegroundConverter x:Key="LogKindForeground"/>
</ResourceDictionary> </ResourceDictionary>
</Application.Resources> </Application.Resources>
@@ -32,7 +31,6 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" /> <StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
<!-- Global defaults: every Window inherits Inter Tight + body size. <!-- Global defaults: every Window inherits Inter Tight + body size.
Controls that need mono opt in via their own class/style. --> Controls that need mono opt in via their own class/style. -->

View File

@@ -1,6 +1,5 @@
using System; using System;
using Avalonia; using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
@@ -33,10 +32,6 @@ public partial class App : Application
FocusClearing.Install(); FocusClearing.Install();
// The main window is authoritative — closing it shuts the app down even if the
// modeless Mission Control window is still open.
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
desktop.MainWindow = new MainWindow desktop.MainWindow = new MainWindow
{ {
DataContext = services.GetRequiredService<IslandsShellViewModel>(), DataContext = services.GetRequiredService<IslandsShellViewModel>(),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -19,8 +19,8 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
## DI Registration Pattern ## DI Registration Pattern
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`/`WorkerPrimeScheduleApi`, `INotesApi`/`WorkerNotesApi`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext) - **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`, `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`) - **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation
## Notes ## Notes

View File

@@ -14,22 +14,22 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.4" /> <PackageReference Include="Avalonia" Version="12.0.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" /> <PackageReference Include="Avalonia.Desktop" Version="12.0.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" /> <PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.0" />
<!-- Direct ref so the App.axaml AvaloniaEdit theme (avares://AvaloniaEdit/...) resolves at runtime. -->
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0"> <PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets> <IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ClaudeDo.Ui\ClaudeDo.Ui.csproj" /> <ProjectReference Include="..\ClaudeDo.Ui\ClaudeDo.Ui.csproj" />
<ProjectReference Include="..\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
<ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" /> <ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" />
</ItemGroup> </ItemGroup>
<Import Project="..\ClaudeDo.Localization\Locales.targets" /> <Import Project="..\ClaudeDo.Localization\Locales.targets" />

View File

@@ -13,6 +13,8 @@ using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Modals.Settings; using ClaudeDo.Ui.ViewModels.Modals.Settings;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using System; using System;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
@@ -77,6 +79,12 @@ sealed class Program
var sc = new ServiceCollection(); var sc = new ServiceCollection();
var logRoot = Path.Combine(Path.GetDirectoryName(dbPath)!, "logs");
var serilogLogger = ClaudeDo.Logging.LoggingSetup
.Configure(new LoggerConfiguration(), "app", logRoot)
.CreateLogger();
sc.AddLogging(b => b.AddSerilog(serilogLogger, dispose: true));
// Infrastructure // Infrastructure
sc.AddSingleton(settings); sc.AddSingleton(settings);
var localesDir = Path.Combine(AppContext.BaseDirectory, "locales"); var localesDir = Path.Combine(AppContext.BaseDirectory, "locales");
@@ -98,7 +106,9 @@ sealed class Program
// Services // Services
sc.AddSingleton<GitService>(); sc.AddSingleton<GitService>();
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl)); sc.AddSingleton(sp => new WorkerClient(
sp.GetRequiredService<AppSettings>().SignalRUrl,
sp.GetRequiredService<ILogger<WorkerClient>>()));
sc.AddSingleton<IWorkerClient>(sp => sp.GetRequiredService<WorkerClient>()); sc.AddSingleton<IWorkerClient>(sp => sp.GetRequiredService<WorkerClient>());
// Release check + installer update // Release check + installer update
@@ -116,18 +126,13 @@ sealed class Program
return new UpdateCheckService(releases, version); return new UpdateCheckService(releases, version);
}); });
// Conflict-merge coordinator: single seam the shell wires to its resolver entry.
sc.AddSingleton<MergeCoordinator>();
sc.AddSingleton<IMergeCoordinator>(sp => sp.GetRequiredService<MergeCoordinator>());
// ViewModels // ViewModels
sc.AddTransient<DiffViewerViewModel>(); sc.AddTransient<WorktreeModalViewModel>();
sc.AddTransient<Func<DiffViewerViewModel>>(sp => () => sp.GetRequiredService<DiffViewerViewModel>()); sc.AddTransient<Func<WorktreeModalViewModel>>(sp => () => sp.GetRequiredService<WorktreeModalViewModel>());
sc.AddTransient<WorktreesOverviewModalViewModel>(); sc.AddTransient<WorktreesOverviewModalViewModel>();
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>()); sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>(); sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
sc.AddSingleton<INotesApi, WorkerNotesApi>(); sc.AddSingleton<INotesApi, WorkerNotesApi>();
sc.AddSingleton<IOnlineLoginService, OnlineLoginService>();
sc.AddTransient<PrimeClaudeTabViewModel>(); sc.AddTransient<PrimeClaudeTabViewModel>();
sc.AddTransient<SettingsModalViewModel>(); sc.AddTransient<SettingsModalViewModel>();
sc.AddTransient<MergeModalViewModel>(); sc.AddTransient<MergeModalViewModel>();
@@ -137,39 +142,24 @@ sealed class Program
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>()); sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
sc.AddTransient<WeeklyReportModalViewModel>(); sc.AddTransient<WeeklyReportModalViewModel>();
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>()); sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
sp.GetRequiredService<IWorkerClient>(), taskId));
// Islands shell VMs // Islands shell VMs
sc.AddSingleton<ListsIslandViewModel>(sp => sc.AddSingleton<ListsIslandViewModel>(sp =>
new ListsIslandViewModel( new ListsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp, sp,
sp.GetRequiredService<IWorkerClient>())); sp.GetRequiredService<WorkerClient>()));
sc.AddSingleton<TasksIslandViewModel>(sp => sc.AddSingleton<TasksIslandViewModel>(sp =>
new TasksIslandViewModel( new TasksIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>())); sp.GetRequiredService<WorkerClient>()));
sc.AddSingleton<DetailsIslandViewModel>(sp => sc.AddSingleton<DetailsIslandViewModel>(sp =>
new DetailsIslandViewModel( new DetailsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>(), sp.GetRequiredService<WorkerClient>(),
sp, sp,
sp.GetRequiredService<INotesApi>(), sp.GetRequiredService<INotesApi>()));
sp.GetRequiredService<IMergeCoordinator>())); sc.AddSingleton<IslandsShellViewModel>();
sc.AddSingleton<MissionControlViewModel>(sp =>
new MissionControlViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>()));
sc.AddSingleton<IslandsShellViewModel>(sp =>
{
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
shell.ConflictResolverFactory =
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
sp.GetRequiredService<MergeCoordinator>().Handler = shell.RequestConflictResolutionAsync;
return shell;
});
return sc.BuildServiceProvider(); return sc.BuildServiceProvider();
} }

View File

@@ -1,85 +0,0 @@
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) { }
}
}

View File

@@ -4,7 +4,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Models ## Models
- **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. - **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.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable) - **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable)
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept) - **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
@@ -12,7 +12,6 @@ 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. - **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` - **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) - **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) - **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 - **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
@@ -20,13 +19,12 @@ 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. 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`, `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`, `TryCompleteParentAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
- **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config` - **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync` - **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository** - **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
- **DailyNoteRepository** — `ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync` - **DailyNoteRepository** — `ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync`
- **WeekReportRepository** — `GetByRangeAsync`, `UpsertAsync` - **WeekReportRepository** — `GetByRangeAsync`, `UpsertAsync`
- **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`, `DeleteAllForTaskAsync`
## Infrastructure ## Infrastructure
@@ -34,15 +32,14 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
- **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service) - **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service)
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app` - **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl) - **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
- **AttachmentStore** — dependency-free file store; default root `~/.todo-app/attachments/<taskId>/`. `SaveAsync` enforces a 5 MB cap and path-traversal/containment guard. Also exposes `DeleteFile`, `DeleteTaskDir`, `TaskDir`, `Root`, and `EnumerateTaskIds` (used by the worker orphan sweep). Attachment files live outside git worktrees intentionally.
## Git ## Git
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). 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 - **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo
## Schema ## Schema
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). 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).
## Conventions ## Conventions

View File

@@ -1,8 +1,6 @@
using System.Data.Common;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Seeding; using ClaudeDo.Data.Seeding;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -11,42 +9,14 @@ namespace ClaudeDo.Data;
public class ClaudeDoDbContext : DbContext public class ClaudeDoDbContext : DbContext
{ {
// Runs PRAGMA foreign_keys=ON on every EF-managed connection open so FK
// enforcement is active for all IDbContextFactory-created contexts, not
// just the single context used in MigrateAndConfigure.
private sealed class SqliteForeignKeyInterceptor : DbConnectionInterceptor
{
internal static readonly SqliteForeignKeyInterceptor Instance = new();
public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
=> Apply(connection);
public override Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default)
{
Apply(connection);
return Task.CompletedTask;
}
private static void Apply(DbConnection connection)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA foreign_keys=ON;";
cmd.ExecuteNonQuery();
}
}
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { } public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance);
public DbSet<TaskEntity> Tasks => Set<TaskEntity>(); public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>(); public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>(); public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>(); public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>(); public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>(); public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
public DbSet<TaskAttachmentEntity> TaskAttachments => Set<TaskAttachmentEntity>();
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>(); public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>(); public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>(); public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();

View File

@@ -1,27 +0,0 @@
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");
}
}

View File

@@ -7,7 +7,8 @@ public sealed class ReviewFilter : ITaskListFilter
{ {
public string Id => "virtual:review"; public string Id => "virtual:review";
public bool Matches(TaskEntity t) => public bool Matches(TaskEntity t) =>
t.Status == TaskStatus.WaitingForReview; t.Status == TaskStatus.Done &&
t.Worktree is { State: WorktreeState.Active };
public bool ShouldCount(TaskEntity t) => Matches(t); public bool ShouldCount(TaskEntity t) => Matches(t);
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false; public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
} }

View File

@@ -1,134 +0,0 @@
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;
}
}

View File

@@ -3,15 +3,8 @@ using System.Text;
namespace ClaudeDo.Data.Git; namespace ClaudeDo.Data.Git;
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
public sealed class GitService 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) public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
{ {
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct); var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct);
@@ -28,30 +21,10 @@ public sealed class GitService
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default) public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
{ {
await WorktreeAddGate.WaitAsync(ct); var (exitCode, _, stderr) = await RunGitAsync(repoDir,
try ["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
{ if (exitCode != 0)
const int maxAttempts = 3; throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
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) public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
@@ -124,20 +97,6 @@ public sealed class GitService
return await GetDiffAsync(worktreePath, ct); 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) public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
@@ -252,11 +211,8 @@ public sealed class GitService
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync( public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
string repoDir, string sourceBranch, string message, CancellationToken ct = default) 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, var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch], ct); ["merge", "--no-ff", "-m", message, sourceBranch], ct);
return (exitCode, stderr); return (exitCode, stderr);
} }
@@ -280,56 +236,6 @@ public sealed class GitService
.ToList(); .ToList();
} }
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}");
}
/// <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);
}
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default) public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
{ {
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct); var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
@@ -338,7 +244,7 @@ public sealed class GitService
} }
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync( private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true) string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null)
{ {
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
{ {
@@ -387,6 +293,6 @@ public sealed class GitService
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd()); return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
} }
} }

View File

@@ -1,696 +0,0 @@
// <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
}
}
}

View File

@@ -1,30 +0,0 @@
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)
{
}
}
}

View File

@@ -1,739 +0,0 @@
// <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
}
}
}

View File

@@ -1,48 +0,0 @@
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");
}
}
}

View File

@@ -294,38 +294,6 @@ namespace ClaudeDo.Data.Migrations
b.ToTable("subtasks", (string)null); 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 => modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -657,17 +625,6 @@ namespace ClaudeDo.Data.Migrations
b.Navigation("Task"); 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 => modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{ {
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)

View File

@@ -4,26 +4,9 @@ public static class ModelRegistry
{ {
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku" }; 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 DefaultAlias = "sonnet";
public const string PlanningAlias = "opus"; public const string PlanningAlias = "opus";
public const string ListDefaultSentinel = "(default)"; public const string ListDefaultSentinel = "(default)";
public const string TaskInheritSentinel = "(inherit)"; 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)}.");
}
} }

View File

@@ -1,13 +0,0 @@
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!;
}

View File

@@ -2,7 +2,7 @@ using System.Text;
namespace ClaudeDo.Data; namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine } public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild }
public static class PromptFiles public static class PromptFiles
{ {
@@ -17,7 +17,6 @@ public static class PromptFiles
PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"), PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"),
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"), PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"), PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"),
PromptKind.Refine => Path.Combine(Root, "refine.md"),
_ => throw new ArgumentOutOfRangeException(nameof(kind)) _ => throw new ArgumentOutOfRangeException(nameof(kind))
}; };
@@ -62,7 +61,6 @@ public static class PromptFiles
PromptKind.DailyPrep => DailyPrepDefault, PromptKind.DailyPrep => DailyPrepDefault,
PromptKind.WeeklyReport => WeeklyReportDefault, PromptKind.WeeklyReport => WeeklyReportDefault,
PromptKind.ImprovementChild => ImprovementChildDefault, PromptKind.ImprovementChild => ImprovementChildDefault,
PromptKind.Refine => RefineDefault,
_ => "" _ => ""
}; };
@@ -82,10 +80,7 @@ public static class PromptFiles
## Out-of-scope improvements ## Out-of-scope improvements
If you notice worthwhile work that is genuinely outside this task's scope 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 (a refactor, a follow-up, tech debt), do NOT do it here. File it with
SuggestImprovement(title, description, model) and stay focused on the task at hand. SuggestImprovement(title, description) 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 ## Working in the repo
- Read a file before editing it. Match the conventions already in this codebase - Read a file before editing it. Match the conventions already in this codebase
@@ -105,12 +100,9 @@ public static class PromptFiles
- Don't introduce injection/XSS/secret-leak issues. Never commit credentials. - Don't introduce injection/XSS/secret-leak issues. Never commit credentials.
## You are running unattended ## You are running unattended
You run autonomously, usually with no one watching. Default to making the most You run autonomously with no human watching. There is no one to answer mid-task
reasonable decision yourself, noting the assumption, and continuing do not stop questions, so never stop to ask make the most reasonable decision, note the
for routine choices. The one exception: at a genuine fork where a wrong guess assumption, and continue.
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 ## When you are blocked
If something genuinely prevents you from completing part of the task (missing If something genuinely prevents you from completing part of the task (missing
@@ -128,8 +120,8 @@ public static class PromptFiles
# Out-of-scope follow-up # Out-of-scope follow-up
You are an improvement follow-up that another task filed via SuggestImprovement. You are an improvement follow-up that another task filed via SuggestImprovement.
It was deliberately scoped narrow, and is intentionally a small, cheap unit of It was deliberately scoped narrow. Do EXACTLY what this task's title and
work. Do EXACTLY what this task's title and description ask nothing more. description ask nothing more.
- Make the smallest change that satisfies the task. No opportunistic refactors, - Make the smallest change that satisfies the task. No opportunistic refactors,
renames, reformatting, or "while I'm here" cleanup beyond what is asked. renames, reformatting, or "while I'm here" cleanup beyond what is asked.
@@ -156,14 +148,6 @@ public static class PromptFiles
Once the design is approved, create the child tasks with CreateChildTask, then Once the design is approved, create the child tasks with CreateChildTask, then
call Finalize. Keep each subtask concrete and self-contained with a clear call Finalize. Keep each subtask concrete and self-contained with a clear
done-state, ordered so dependencies come first. 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 = """ private const string PlanningInitialDefault = """
@@ -197,33 +181,6 @@ public static class PromptFiles
If there are no candidates, do nothing. 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 WeeklyReportDefault = """ private const string WeeklyReportDefault = """
You are generating a concise weekly standup report for a software developer, You are generating a concise weekly standup report for a software developer,
covering {start} to {end}. covering {start} to {end}.

View File

@@ -18,18 +18,8 @@ public sealed class AppSettingsRepository
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }; row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
_context.AppSettings.Add(row); _context.AppSettings.Add(row);
try await _context.SaveChangesAsync(ct);
{ _context.Entry(row).State = EntityState.Detached;
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; return row;
} }

View File

@@ -6,13 +6,8 @@ namespace ClaudeDo.Data.Repositories;
public sealed class ListRepository public sealed class ListRepository
{ {
private readonly ClaudeDoDbContext _context; private readonly ClaudeDoDbContext _context;
private readonly AttachmentStore _attachments;
public ListRepository(ClaudeDoDbContext context, AttachmentStore? attachments = null) public ListRepository(ClaudeDoDbContext context) => _context = context;
{
_context = context;
_attachments = attachments ?? new AttachmentStore();
}
public async Task AddAsync(ListEntity entity, CancellationToken ct = default) public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
{ {
@@ -28,13 +23,7 @@ public sealed class ListRepository
public async Task DeleteAsync(string listId, CancellationToken ct = default) 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); 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) public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)

View File

@@ -1,51 +0,0 @@
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);
}
}

View File

@@ -7,13 +7,8 @@ namespace ClaudeDo.Data.Repositories;
public sealed class TaskRepository public sealed class TaskRepository
{ {
private readonly ClaudeDoDbContext _context; private readonly ClaudeDoDbContext _context;
private readonly AttachmentStore _attachments;
public TaskRepository(ClaudeDoDbContext context, AttachmentStore? attachments = null) public TaskRepository(ClaudeDoDbContext context) => _context = context;
{
_context = context;
_attachments = attachments ?? new AttachmentStore();
}
#region CRUD #region CRUD
@@ -42,7 +37,6 @@ public sealed class TaskRepository
public async Task DeleteAsync(string taskId, CancellationToken ct = default) public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{ {
await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct); await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct);
_attachments.DeleteTaskDir(taskId);
} }
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default) public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
@@ -93,22 +87,6 @@ public sealed class TaskRepository
.ToListAsync(ct); .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 #endregion
#region Status transitions #region Status transitions
@@ -219,7 +197,6 @@ public sealed class TaskRepository
string? description, string? description,
string? commitType, string? commitType,
string? createdBy = null, string? createdBy = null,
string? model = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
// AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which // AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which
@@ -246,7 +223,6 @@ public sealed class TaskRepository
ParentTaskId = parentId, ParentTaskId = parentId,
SortOrder = (maxSort ?? -1) + 1, SortOrder = (maxSort ?? -1) + 1,
CreatedBy = createdBy, CreatedBy = createdBy,
Model = ModelRegistry.NormalizeAlias(model),
}; };
_context.Tasks.Add(child); _context.Tasks.Add(child);
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
@@ -498,5 +474,32 @@ public sealed class TaskRepository
return chainIds.Count; 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 #endregion
} }

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Seeding; namespace ClaudeDo.Data.Seeding;
@@ -8,18 +9,17 @@ public static class DefaultListsSeeder
public static async Task SeedAsync(ClaudeDoDbContext ctx, CancellationToken ct = default) 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; var now = DateTime.UtcNow;
foreach (var name in Defaults) foreach (var name in Defaults.Where(n => !existing.Contains(n)))
{ {
var id = Guid.NewGuid().ToString(); ctx.Lists.Add(new ListEntity
// Atomic conditional insert: the SELECT ... WHERE NOT EXISTS is a single {
// SQLite statement and cannot race — only one writer holds the lock. Id = Guid.NewGuid().ToString(),
await ctx.Database.ExecuteSqlAsync( Name = name,
$""" CreatedAt = now,
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);
} }
} }

View File

@@ -1,38 +0,0 @@
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();
}
}

View File

@@ -38,6 +38,104 @@ public partial class App : Application
var localizer = new Localizer(localeStore, initialLang); var localizer = new Localizer(localeStore, initialLang);
TrExtension.Localizer = localizer; 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); _services = BuildServices(localizer);
var context = _services.GetRequiredService<InstallContext>(); var context = _services.GetRequiredService<InstallContext>();

View File

@@ -12,22 +12,14 @@ Note: this is the one project where `System.Windows` is correct (WPF, not Avalon
- Entry point: `App.xaml` / `App.xaml.cs` (no `Program.cs`) - Entry point: `App.xaml` / `App.xaml.cs` (no `Program.cs`)
- References: `ClaudeDo.Data`, `ClaudeDo.Releases`, `ClaudeDo.Localization` - References: `ClaudeDo.Data`, `ClaudeDo.Releases`, `ClaudeDo.Localization`
- Manifests: `app.manifest` (requireAdministrator, Release) / `app.debug.manifest` (asInvoker, Debug) - Manifests: `app.manifest` (requireAdministrator, Release) / `app.debug.manifest` (asInvoker, Debug)
- No CLI args — mode is detected from `install.json` + the Gitea API - Only CLI arg: `--replace-self <old-path>` (self-update handoff)
## Startup Sequence (`App.OnStartup`) ## Startup Sequence (`App.OnStartup`)
1. Load locale 1. Load locale
2. Detect mode — `InstallModeDetector` reads `install.json` + Gitea API 2. Self-update preflight — `SelfUpdater.DecideUpdateAsync` checks Gitea API; if a newer installer exists, download + checksum verify + relaunch with `--replace-self <old-path>`
3. Open `WizardWindow` (FreshInstall / Update) or `SettingsWindow` (Config) 3. Detect mode — `InstallModeDetector` reads `install.json` + Gitea API
4. 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`) ## Modes (`Core/InstallerMode.cs`)
@@ -64,7 +56,8 @@ Installer/
Interfaces/ — IInstallStep + StepResult/StepStatus/StepProgress, IInstallerPage Interfaces/ — IInstallStep + StepResult/StepStatus/StepProgress, IInstallerPage
Pages/ — WelcomePage, PathsPage, ServicePage, UiSettingsPage, InstallPage Pages/ — WelcomePage, PathsPage, ServicePage, UiSettingsPage, InstallPage
(each: ViewModel + View.xaml) (each: ViewModel + View.xaml)
Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel) Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel),
SelfUpdatePromptWindow
``` ```
## Key Step Behaviors ## Key Step Behaviors

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 374 B

View File

@@ -26,9 +26,9 @@ public sealed class WriteUninstallRegistryStep : IInstallStep
// the single-file temp extract is gone once this process exits. // the single-file temp extract is gone once this process exits.
var sourceExe = Environment.ProcessPath var sourceExe = Environment.ProcessPath
?? throw new InvalidOperationException("Cannot resolve running installer path."); ?? throw new InvalidOperationException("Cannot resolve running installer path.");
// When relaunched from the installed copy (e.g. the Apps & Features "Rerun // In the self-update path the installer already runs from uninstaller/ (the
// Installer" entry points at uninstaller/ClaudeDo.Installer.exe), source == target // --replace-self handoff put it there), so source == target and the copy would
// and the copy would throw. Skip it; the binary is already in place. // throw. Skip it; the binary is already in place.
var alreadyInPlace = string.Equals( var alreadyInPlace = string.Equals(
Path.GetFullPath(sourceExe), Path.GetFullPath(targetExe), StringComparison.OrdinalIgnoreCase); Path.GetFullPath(sourceExe), Path.GetFullPath(targetExe), StringComparison.OrdinalIgnoreCase);
if (!alreadyInPlace) if (!alreadyInPlace)

View File

@@ -0,0 +1,26 @@
<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>

View File

@@ -0,0 +1,42 @@
using System.Windows;
namespace ClaudeDo.Installer.Views;
public enum SelfUpdateChoice { Update, Continue, Cancel }
public partial class SelfUpdatePromptWindow : Window
{
public SelfUpdateChoice Choice { get; private set; } = SelfUpdateChoice.Cancel;
public SelfUpdatePromptWindow(string currentVersion, string latestVersion)
{
InitializeComponent();
DetailText.Text = $"Installer v{latestVersion} is available (you are running v{currentVersion}). Update before continuing?";
}
public void ShowProgress(string text)
{
ProgressText.Text = text;
ProgressText.Visibility = Visibility.Visible;
UpdateBtn.IsEnabled = false;
ContinueBtn.IsEnabled = false;
}
private void UpdateBtn_Click(object sender, RoutedEventArgs e)
{
Choice = SelfUpdateChoice.Update;
DialogResult = true;
}
private void ContinueBtn_Click(object sender, RoutedEventArgs e)
{
Choice = SelfUpdateChoice.Continue;
DialogResult = true;
}
private void CancelBtn_Click(object sender, RoutedEventArgs e)
{
Choice = SelfUpdateChoice.Cancel;
DialogResult = false;
}
}

View File

@@ -53,7 +53,6 @@
"prime": { "prime": {
"description": "Bereite dein Claude-Nutzungsfenster vor, indem an den von dir gewählten Tagen zu einer bestimmten Zeit ein einzelner nicht-interaktiver Ping ausgelöst wird. Läuft nur, solange ClaudeDo geöffnet ist. Wenn die App innerhalb von 30 Minuten vor der Zielzeit startet, wird der Ping sofort ausgelöst.", "description": "Bereite dein Claude-Nutzungsfenster vor, indem an den von dir gewählten Tagen zu einer bestimmten Zeit ein einzelner nicht-interaktiver Ping ausgelöst wird. Läuft nur, solange ClaudeDo geöffnet ist. Wenn die App innerhalb von 30 Minuten vor der Zielzeit startet, wird der Ping sofort ausgelöst.",
"addSchedule": "+ Zeitplan hinzufügen", "addSchedule": "+ Zeitplan hinzufügen",
"removeScheduleTip": "Zeitplan entfernen",
"dailyPrepMaxTasks": "Max. Aufgaben pro Tag", "dailyPrepMaxTasks": "Max. Aufgaben pro Tag",
"dayMo": "Mo", "dayMo": "Mo",
"dayTu": "Di", "dayTu": "Di",
@@ -63,39 +62,11 @@
"daySa": "Sa", "daySa": "Sa",
"daySu": "So" "daySu": "So"
}, },
"onlineInbox": {
"tabHeader": "Online-Posteingang",
"enabledLabel": "Online-Posteingang-Sync aktivieren",
"restartHint": "Aktivieren oder Deaktivieren wird erst nach einem Worker-Neustart wirksam.",
"apiBaseUrlLabel": "API-Basis-URL",
"apiBaseUrlPlaceholder": "https://inbox.claudedo.kuns.dev",
"authorityLabel": "Zitadel-Authority (Issuer-URL)",
"authorityPlaceholder": "https://auth.example.com",
"clientIdLabel": "Client-ID",
"scopesLabel": "Scopes",
"redirectUriLabel": "Redirect-URI",
"pollIntervalLabel": "Abfrageintervall (Sekunden)",
"statusSection": "AUTH-STATUS",
"signedInStatus": "Angemeldet",
"signedOutStatus": "Nicht angemeldet",
"signInButton": "Im Browser anmelden",
"signOutButton": "Abmelden",
"configSection": "KONFIGURATION",
"saveButton": "Konfiguration speichern"
},
"inherit": { "inherit": {
"inheritedFromList": "geerbt · Liste", "inheritedFromList": "geerbt · Liste",
"inheritedFromGlobal": "geerbt · Global", "inheritedFromGlobal": "geerbt · Global",
"overrideBadge": "überschrieben", "overrideBadge": "überschrieben",
"resetToInherited": "Auf geerbt zurücksetzen" "resetToInherited": "Auf geerbt zurücksetzen"
},
"agentEditor": {
"model": "Modell",
"maxTurns": "Max. Durchläufe",
"systemPrompt": "System-Prompt (angehängt)",
"promptPrepended": "Wird automatisch vorangestellt:",
"agentFile": "Agent-Datei",
"browse": "Durchsuchen..."
} }
}, },
"tasks": { "tasks": {
@@ -118,12 +89,10 @@
"ctxRunInteractively": "Interaktiv ausführen", "ctxRunInteractively": "Interaktiv ausführen",
"ctxOpenPlanningSession": "Planungssitzung öffnen", "ctxOpenPlanningSession": "Planungssitzung öffnen",
"ctxResumePlanningSession": "Planungssitzung fortsetzen", "ctxResumePlanningSession": "Planungssitzung fortsetzen",
"ctxFinalizePlanningSession": "Plan finalisieren",
"ctxDiscardPlanningSession": "Planungssitzung verwerfen", "ctxDiscardPlanningSession": "Planungssitzung verwerfen",
"ctxQueueSubtasks": "Teilaufgaben nacheinander einreihen",
"ctxScheduleFor": "Planen für...", "ctxScheduleFor": "Planen für...",
"ctxClearSchedule": "Zeitplan entfernen", "ctxClearSchedule": "Zeitplan entfernen",
"ctxRemoveFromMyDay": "Aus Mein Tag entfernen",
"ctxAddToMyDay": "Zu Mein Tag hinzufügen",
"badgeDraft": "ENTWURF", "badgeDraft": "ENTWURF",
"badgePlanned": "GEPLANT", "badgePlanned": "GEPLANT",
"approve": "Genehmigen", "approve": "Genehmigen",
@@ -135,8 +104,6 @@
"cancel": "Abbrechen", "cancel": "Abbrechen",
"cancelTip": "Diese Aufgabe abbrechen", "cancelTip": "Diese Aufgabe abbrechen",
"removeFromQueueTip": "Aus Warteschlange entfernen", "removeFromQueueTip": "Aus Warteschlange entfernen",
"toggleSubtasksTip": "Unteraufgaben ein-/ausklappen",
"agentSuggestedTip": "Vom Agenten vorgeschlagen",
"scheduleTitle": "Aufgabe planen", "scheduleTitle": "Aufgabe planen",
"scheduleWhen": "WANN", "scheduleWhen": "WANN",
"scheduleConfirm": "Planen", "scheduleConfirm": "Planen",
@@ -144,8 +111,7 @@
"reviewTitle": "Review", "reviewTitle": "Review",
"feedbackLabel": "FEEDBACK FÜR DEN AGENTEN", "feedbackLabel": "FEEDBACK FÜR DEN AGENTEN",
"feedbackPlaceholder": "Was soll der Agent korrigieren?", "feedbackPlaceholder": "Was soll der Agent korrigieren?",
"rerun": "Erneut ausführen", "rerun": "Erneut ausführen"
"refineTip": "Aufgabe mit Claude verfeinern"
}, },
"lists": { "lists": {
"heading": "Listen", "heading": "Listen",
@@ -163,12 +129,16 @@
}, },
"details": { "details": {
"deleteTaskTip": "Aufgabe löschen", "deleteTaskTip": "Aufgabe löschen",
"killSessionTip": "Laufende Sitzung beenden",
"closeTip": "Schließen", "closeTip": "Schließen",
"copyTaskIdTip": "Aufgaben-ID kopieren", "copyTaskIdTip": "Aufgaben-ID kopieren",
"starTip": "Favorit", "starTip": "Favorit",
"agentSettingsTip": "Agent-Einstellungen", "agentSettingsTip": "Agent-Einstellungen",
"agentSettingsHeading": "Agent-Einstellungen (Überschreibungen)", "agentSettingsHeading": "Agent-Einstellungen (Überschreibungen)",
"modelLabel": "Modell",
"maxTurnsLabel": "Max. Durchläufe",
"systemPromptLabel": "System-Prompt (angehängt)",
"systemPromptPrepended": "Wird automatisch vorangestellt:",
"agentFileLabel": "Agent-Datei",
"mergeLabel": "MERGE", "mergeLabel": "MERGE",
"mergeTargetLabel": "Merge-Ziel", "mergeTargetLabel": "Merge-Ziel",
"reviewCombinedDiff": "Kombiniertes Diff prüfen", "reviewCombinedDiff": "Kombiniertes Diff prüfen",
@@ -178,29 +148,13 @@
"addStepPlaceholder": "Schritt hinzufügen...", "addStepPlaceholder": "Schritt hinzufügen...",
"detailsLabel": "DETAILS", "detailsLabel": "DETAILS",
"copyDescriptionTip": "Beschreibung in die Zwischenablage kopieren", "copyDescriptionTip": "Beschreibung in die Zwischenablage kopieren",
"copyFormattedTip": "Titel, Beschreibung und offene Schritte kopieren",
"toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten", "toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten",
"previewBtn": "Vorschau", "previewBtn": "Vorschau",
"editBtn": "Bearbeiten", "editBtn": "Bearbeiten",
"descriptionPlaceholder": "Aufgabendetails hinzufügen (Markdown unterstützt)...", "descriptionPlaceholder": "Aufgabendetails hinzufügen (Markdown unterstützt)...",
"prepTitle": "Tagesvorbereitung", "prepTitle": "Tagesvorbereitung",
"planDay": "Tag planen", "planDay": "Tag planen",
"prepEmpty": "Heute noch keine Vorbereitung — klick Tag planen", "prepEmpty": "Heute noch keine Vorbereitung — klick Tag planen"
"attachments": {
"sectionLabel": "ANHÄNGE",
"dropToAttach": "Zum Anhängen ablegen",
"addFile": "Datei hinzufügen…",
"removeTip": "Anhang entfernen",
"addedSummary": "✓ Hinzugefügt: {0} ({1} Datei(en))",
"overLimitError": "Konnte {0} nicht hinzufügen: {1}",
"invalidNameError": "Konnte {0} nicht hinzufügen: {1}",
"selectIdleTask": "Zuerst eine inaktive Aufgabe auswählen"
},
"sections": {
"description": "Beschreibung",
"steps": "Schritte",
"files": "Dateien"
}
}, },
"agent": { "agent": {
"stopTip": "Agent stoppen", "stopTip": "Agent stoppen",
@@ -229,45 +183,9 @@
"session": { "session": {
"chipLive": "LIVE", "chipLive": "LIVE",
"chipDone": "FERTIG", "chipDone": "FERTIG",
"chipFailed": "FEHLGESCHLAGEN", "chipFailed": "FEHLGESCHLAGEN"
"reviewContinueTip": "Dieses Feedback senden und die Aufgabe erneut ausführen",
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen",
"composer": {
"placeholder": "Nachricht an die Sitzung…",
"send": "Senden",
"stop": "Sitzung beenden",
"interrupt": "Aktuellen Zug unterbrechen",
"queued": "Wartet — wird nach dem aktuellen Zug gesendet",
"unqueue": "Aus Warteschlange entfernen"
}
},
"missionControl": {
"openInApp": "In App öffnen",
"cancel": "Abbrechen",
"detach": "Abdocken",
"redock": "Andocken",
"windowTitle": "Mission Control",
"clearFinished": "Erledigte entfernen",
"empty": "Keine laufenden Aufgaben",
"settings": "Einstellungen",
"queue": "Warteschlange",
"blocked": "Blockiert",
"question": {
"title": "Claude fragt nach",
"placeholder": "Antwort eingeben…",
"send": "Senden"
}
}, },
"modals": { "modals": {
"logVisualizer": {
"title": "WORKER-LOGS — LETZTE 30 MIN",
"warnErrorOnly": "Nur Warnungen & Fehler",
"refresh": "Aktualisieren",
"empty": "Keine Logs in den letzten 30 Minuten.",
"count": "{0} Einträge",
"footerHint": "logs",
"openTooltip": "Aktuelle Worker-Logs anzeigen"
},
"about": { "about": {
"title": "ÜBER", "title": "ÜBER",
"version": "Version", "version": "Version",
@@ -293,7 +211,11 @@
"browse": "Durchsuchen...", "browse": "Durchsuchen...",
"defaultCommitType": "Standard-Commit-Typ", "defaultCommitType": "Standard-Commit-Typ",
"sectionAgent": "AGENT", "sectionAgent": "AGENT",
"resetAgentSettings": "Agent-Einstellungen zurücksetzen" "resetAgentSettings": "Agent-Einstellungen zurücksetzen",
"model": "Modell",
"maxTurns": "Max. Durchläufe",
"systemPrompt": "System-Prompt (angehängt)",
"agentFile": "Agent-Datei"
}, },
"merge": { "merge": {
"title": "WORKTREE MERGEN", "title": "WORKTREE MERGEN",
@@ -308,10 +230,10 @@
"diff": { "diff": {
"title": "DIFF", "title": "DIFF",
"windowTitle": "Diff", "windowTitle": "Diff",
"merge": "Mergen…", "merge": "Mergen…"
"filesHeader": "Dateien", },
"binary": "Binärdatei — kein Text-Diff", "worktree": {
"empty": "Kein Inhalt" "title": "Worktree"
}, },
"worktreesOverview": { "worktreesOverview": {
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
@@ -320,12 +242,6 @@
"columnState": "STATUS", "columnState": "STATUS",
"columnDiff": "DIFF", "columnDiff": "DIFF",
"columnAge": "ALTER", "columnAge": "ALTER",
"columnOutcome": "ERGEBNIS",
"selectAll": "Alle auswählen",
"targetLabel": "Ziel",
"mergeAll": "Alle mergen",
"needsResolution": "ZU LÖSEN",
"resolve": "Lösen",
"phantom": "Phantom", "phantom": "Phantom",
"phantomTooltip": "Verzeichnis fehlt auf der Festplatte", "phantomTooltip": "Verzeichnis fehlt auf der Festplatte",
"ctxShowDiff": "Diff anzeigen", "ctxShowDiff": "Diff anzeigen",
@@ -435,28 +351,12 @@
"abort": "Diesen Merge abbrechen" "abort": "Diesen Merge abbrechen"
}, },
"diff": { "diff": {
"windowTitle": "Planung — Kombiniertes Diff",
"modalTitle": "PLANUNG — KOMBINIERTES DIFF",
"previewCombined": "Kombinierte Vorschau", "previewCombined": "Kombinierte Vorschau",
"loading": "Wird geladen…" "loading": "Wird geladen…"
} }
}, },
"conflictResolver": {
"windowTitle": "Merge-Konflikte lösen",
"modalTitle": "KONFLIKTE LÖSEN",
"loading": "Konflikte werden geladen…",
"ours": "MAIN · Ziel-Branch",
"result": "ERGEBNIS",
"theirs": "INCOMING · Task-Branch",
"binaryHint": "Binärdateien können hier nicht zusammengeführt werden — brich ab und löse sie in deinem Editor:",
"prevConflict": "Vorheriger Konflikt (Umschalt+F8)",
"nextConflict": "Nächster Konflikt (F8)",
"conflictMap": "Konflikte in dieser Datei — Marker anklicken zum Springen",
"acceptOurs": "Main hinzufügen",
"acceptTheirs": "Incoming hinzufügen",
"removeOurs": "Main entfernen",
"removeTheirs": "Incoming entfernen",
"continue": "Lösen & fortfahren",
"abort": "Merge abbrechen"
},
"controls": { "controls": {
"datePicker": { "datePicker": {
"today": "Heute", "today": "Heute",
@@ -470,8 +370,6 @@
"shell": { "shell": {
"menu": { "menu": {
"help": "Hilfe", "help": "Hilfe",
"worker": "Worker",
"repositories": "Repositories",
"checkForUpdates": "Nach Updates suchen", "checkForUpdates": "Nach Updates suchen",
"restartWorker": "Worker neu starten", "restartWorker": "Worker neu starten",
"worktrees": "Worktrees…", "worktrees": "Worktrees…",
@@ -489,20 +387,19 @@
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" }, "connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
"shell": { "restartingWorker": "Worker wird neu gestartet…" }, "shell": { "restartingWorker": "Worker wird neu gestartet…" },
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" }, "agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt" }, "taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" }, "planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" }, "taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "runInteractiveFailed": "Interaktiv ausführen fehlgeschlagen: {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}" }, "tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}" },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." }, "diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien)." }, "planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien)." },
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" }, "merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" }, "conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" }, "settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" }, "weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt." }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt." },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen." },
"listSettings": { "untitled": "Unbenannt" }, "listSettings": { "untitled": "Unbenannt" },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" } "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
} }

View File

@@ -53,7 +53,6 @@
"prime": { "prime": {
"description": "Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately.", "description": "Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately.",
"addSchedule": "+ Add schedule", "addSchedule": "+ Add schedule",
"removeScheduleTip": "Remove schedule",
"dailyPrepMaxTasks": "Max tasks per day", "dailyPrepMaxTasks": "Max tasks per day",
"dayMo": "Mo", "dayMo": "Mo",
"dayTu": "Tu", "dayTu": "Tu",
@@ -63,39 +62,11 @@
"daySa": "Sa", "daySa": "Sa",
"daySu": "Su" "daySu": "Su"
}, },
"onlineInbox": {
"tabHeader": "Online Inbox",
"enabledLabel": "Enable online inbox sync",
"restartHint": "Enabling or disabling takes effect after a Worker restart.",
"apiBaseUrlLabel": "API base URL",
"apiBaseUrlPlaceholder": "https://inbox.claudedo.kuns.dev",
"authorityLabel": "Zitadel authority (issuer URL)",
"authorityPlaceholder": "https://auth.example.com",
"clientIdLabel": "Client ID",
"scopesLabel": "Scopes",
"redirectUriLabel": "Redirect URI",
"pollIntervalLabel": "Poll interval (seconds)",
"statusSection": "AUTH STATUS",
"signedInStatus": "Signed in",
"signedOutStatus": "Not signed in",
"signInButton": "Sign in via browser",
"signOutButton": "Sign out",
"configSection": "CONFIGURATION",
"saveButton": "Save config"
},
"inherit": { "inherit": {
"inheritedFromList": "inherited · List", "inheritedFromList": "inherited · List",
"inheritedFromGlobal": "inherited · Global", "inheritedFromGlobal": "inherited · Global",
"overrideBadge": "override", "overrideBadge": "override",
"resetToInherited": "Reset to inherited" "resetToInherited": "Reset to inherited"
},
"agentEditor": {
"model": "Model",
"maxTurns": "Max turns",
"systemPrompt": "System prompt (appended)",
"promptPrepended": "Prepended automatically:",
"agentFile": "Agent file",
"browse": "Browse..."
} }
}, },
"tasks": { "tasks": {
@@ -118,12 +89,10 @@
"ctxRunInteractively": "Run interactively", "ctxRunInteractively": "Run interactively",
"ctxOpenPlanningSession": "Open planning Session", "ctxOpenPlanningSession": "Open planning Session",
"ctxResumePlanningSession": "Resume planning Session", "ctxResumePlanningSession": "Resume planning Session",
"ctxFinalizePlanningSession": "Finalize plan",
"ctxDiscardPlanningSession": "Discard planning session", "ctxDiscardPlanningSession": "Discard planning session",
"ctxQueueSubtasks": "Queue subtasks sequentially",
"ctxScheduleFor": "Schedule for...", "ctxScheduleFor": "Schedule for...",
"ctxClearSchedule": "Clear schedule", "ctxClearSchedule": "Clear schedule",
"ctxRemoveFromMyDay": "Remove from My Day",
"ctxAddToMyDay": "Add to My Day",
"badgeDraft": "DRAFT", "badgeDraft": "DRAFT",
"badgePlanned": "PLANNED", "badgePlanned": "PLANNED",
"approve": "Approve", "approve": "Approve",
@@ -135,8 +104,6 @@
"cancel": "Cancel", "cancel": "Cancel",
"cancelTip": "Cancel this task", "cancelTip": "Cancel this task",
"removeFromQueueTip": "Remove from queue", "removeFromQueueTip": "Remove from queue",
"toggleSubtasksTip": "Expand / collapse subtasks",
"agentSuggestedTip": "Suggested by the agent",
"scheduleTitle": "Schedule task", "scheduleTitle": "Schedule task",
"scheduleWhen": "WHEN", "scheduleWhen": "WHEN",
"scheduleConfirm": "Schedule", "scheduleConfirm": "Schedule",
@@ -144,8 +111,7 @@
"reviewTitle": "Review", "reviewTitle": "Review",
"feedbackLabel": "FEEDBACK FOR THE AGENT", "feedbackLabel": "FEEDBACK FOR THE AGENT",
"feedbackPlaceholder": "What should the agent fix?", "feedbackPlaceholder": "What should the agent fix?",
"rerun": "Re-run", "rerun": "Re-run"
"refineTip": "Refine this task with Claude"
}, },
"lists": { "lists": {
"heading": "Lists", "heading": "Lists",
@@ -163,12 +129,16 @@
}, },
"details": { "details": {
"deleteTaskTip": "Delete task", "deleteTaskTip": "Delete task",
"killSessionTip": "Kill the running session",
"closeTip": "Close", "closeTip": "Close",
"copyTaskIdTip": "Copy task ID", "copyTaskIdTip": "Copy task ID",
"starTip": "Star", "starTip": "Star",
"agentSettingsTip": "Agent settings", "agentSettingsTip": "Agent settings",
"agentSettingsHeading": "Agent settings (overrides)", "agentSettingsHeading": "Agent settings (overrides)",
"modelLabel": "Model",
"maxTurnsLabel": "Max turns",
"systemPromptLabel": "System prompt (appended)",
"systemPromptPrepended": "Prepended automatically:",
"agentFileLabel": "Agent file",
"mergeLabel": "MERGE", "mergeLabel": "MERGE",
"mergeTargetLabel": "Merge target", "mergeTargetLabel": "Merge target",
"reviewCombinedDiff": "Review combined diff", "reviewCombinedDiff": "Review combined diff",
@@ -178,29 +148,13 @@
"addStepPlaceholder": "Add a step...", "addStepPlaceholder": "Add a step...",
"detailsLabel": "DETAILS", "detailsLabel": "DETAILS",
"copyDescriptionTip": "Copy description to clipboard", "copyDescriptionTip": "Copy description to clipboard",
"copyFormattedTip": "Copy title, description and open steps",
"toggleEditPreviewTip": "Toggle edit/preview", "toggleEditPreviewTip": "Toggle edit/preview",
"previewBtn": "Preview", "previewBtn": "Preview",
"editBtn": "Edit", "editBtn": "Edit",
"descriptionPlaceholder": "Add task details (markdown supported)...", "descriptionPlaceholder": "Add task details (markdown supported)...",
"prepTitle": "Daily prep", "prepTitle": "Daily prep",
"planDay": "Plan day", "planDay": "Plan day",
"prepEmpty": "No prep run today yet — click Plan day", "prepEmpty": "No prep run today yet — click Plan day"
"attachments": {
"sectionLabel": "ATTACHMENTS",
"dropToAttach": "Drop to attach",
"addFile": "Add file…",
"removeTip": "Remove attachment",
"addedSummary": "✓ Added {0} ({1} file(s))",
"overLimitError": "Could not add {0}: {1}",
"invalidNameError": "Could not add {0}: {1}",
"selectIdleTask": "Select an idle task first"
},
"sections": {
"description": "Description",
"steps": "Steps",
"files": "Files"
}
}, },
"agent": { "agent": {
"stopTip": "Stop agent", "stopTip": "Stop agent",
@@ -229,45 +183,9 @@
"session": { "session": {
"chipLive": "LIVE", "chipLive": "LIVE",
"chipDone": "DONE", "chipDone": "DONE",
"chipFailed": "FAILED", "chipFailed": "FAILED"
"reviewContinueTip": "Send this feedback and re-run the task",
"reviewResetTip": "Discard all changes and reset the task to Idle",
"composer": {
"placeholder": "Message the session…",
"send": "Send",
"stop": "Stop session",
"interrupt": "Interrupt current turn",
"queued": "Queued — sends after the current turn",
"unqueue": "Remove from queue"
}
},
"missionControl": {
"openInApp": "Open in app",
"cancel": "Cancel",
"detach": "Detach",
"redock": "Re-dock",
"windowTitle": "Mission Control",
"clearFinished": "Clear finished",
"empty": "No running tasks",
"settings": "Settings",
"queue": "Queue",
"blocked": "Blocked",
"question": {
"title": "Claude is asking",
"placeholder": "Type your answer…",
"send": "Send"
}
}, },
"modals": { "modals": {
"logVisualizer": {
"title": "WORKER LOGS — LAST 30 MIN",
"warnErrorOnly": "Warnings & errors only",
"refresh": "Refresh",
"empty": "No logs in the last 30 minutes.",
"count": "{0} entries",
"footerHint": "logs",
"openTooltip": "View recent worker logs"
},
"about": { "about": {
"title": "ABOUT", "title": "ABOUT",
"version": "Version", "version": "Version",
@@ -293,7 +211,11 @@
"browse": "Browse...", "browse": "Browse...",
"defaultCommitType": "Default commit type", "defaultCommitType": "Default commit type",
"sectionAgent": "AGENT", "sectionAgent": "AGENT",
"resetAgentSettings": "Reset agent settings" "resetAgentSettings": "Reset agent settings",
"model": "Model",
"maxTurns": "Max turns",
"systemPrompt": "System prompt (appended)",
"agentFile": "Agent file"
}, },
"merge": { "merge": {
"title": "MERGE WORKTREE", "title": "MERGE WORKTREE",
@@ -308,10 +230,10 @@
"diff": { "diff": {
"title": "DIFF", "title": "DIFF",
"windowTitle": "Diff", "windowTitle": "Diff",
"merge": "Merge…", "merge": "Merge…"
"filesHeader": "Files", },
"binary": "Binary file — no text diff", "worktree": {
"empty": "No content" "title": "Worktree"
}, },
"worktreesOverview": { "worktreesOverview": {
"refresh": "Refresh", "refresh": "Refresh",
@@ -320,12 +242,6 @@
"columnState": "STATE", "columnState": "STATE",
"columnDiff": "DIFF", "columnDiff": "DIFF",
"columnAge": "AGE", "columnAge": "AGE",
"columnOutcome": "RESULT",
"selectAll": "Select all",
"targetLabel": "Target",
"mergeAll": "Merge all",
"needsResolution": "NEEDS RESOLUTION",
"resolve": "Resolve",
"phantom": "phantom", "phantom": "phantom",
"phantomTooltip": "Directory missing on disk", "phantomTooltip": "Directory missing on disk",
"ctxShowDiff": "Show diff", "ctxShowDiff": "Show diff",
@@ -435,28 +351,12 @@
"abort": "Abort this merge" "abort": "Abort this merge"
}, },
"diff": { "diff": {
"windowTitle": "Planning — Combined diff",
"modalTitle": "PLANNING — COMBINED DIFF",
"previewCombined": "Preview combined", "previewCombined": "Preview combined",
"loading": "Loading…" "loading": "Loading…"
} }
}, },
"conflictResolver": {
"windowTitle": "Resolve merge conflicts",
"modalTitle": "RESOLVE CONFLICTS",
"loading": "Loading conflicts…",
"ours": "MAIN · merge target",
"result": "RESULT",
"theirs": "INCOMING · task branch",
"binaryHint": "Binary files can't be merged here — abort and resolve them in your editor:",
"prevConflict": "Previous conflict (Shift+F8)",
"nextConflict": "Next conflict (F8)",
"conflictMap": "Conflicts in this file — click a marker to jump",
"acceptOurs": "Add main",
"acceptTheirs": "Add incoming",
"removeOurs": "Remove main",
"removeTheirs": "Remove incoming",
"continue": "Resolve & continue",
"abort": "Abort merge"
},
"controls": { "controls": {
"datePicker": { "datePicker": {
"today": "Today", "today": "Today",
@@ -470,8 +370,6 @@
"shell": { "shell": {
"menu": { "menu": {
"help": "Help", "help": "Help",
"worker": "Worker",
"repositories": "Repositories",
"checkForUpdates": "Check for updates", "checkForUpdates": "Check for updates",
"restartWorker": "Restart worker", "restartWorker": "Restart worker",
"worktrees": "Worktrees…", "worktrees": "Worktrees…",
@@ -489,20 +387,19 @@
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" }, "connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
"shell": { "restartingWorker": "Restarting worker…" }, "shell": { "restartingWorker": "Restarting worker…" },
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" }, "agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked" }, "taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" }, "planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" }, "taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "runInteractiveFailed": "Run interactively failed: {0}", "planningOpenFailed": "Couldn't open planning session: {0}" }, "tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." }, "diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files)." }, "planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files)." },
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" }, "merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" }, "conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" }, "settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" }, "weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed." },
"listSettings": { "untitled": "Untitled" }, "listSettings": { "untitled": "Untitled" },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" } "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
} }

View File

@@ -0,0 +1,14 @@
using System.Diagnostics;
using System.Reflection;
namespace ClaudeDo.Logging;
/// <summary>Runtime build-configuration detection — the replacement for #if DEBUG.
/// Debug builds compile with the JIT optimizer disabled; Release builds enable it.</summary>
public static class BuildConfig
{
public static bool IsDebug { get; } =
Assembly.GetEntryAssembly()
?.GetCustomAttribute<DebuggableAttribute>()
?.IsJITOptimizerDisabled ?? false;
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
using Serilog.Core;
using Serilog.Events;
namespace ClaudeDo.Logging;
/// <summary>Ensures every log event carries a TaskId property (defaulting to "-")
/// so the output template's [{TaskId}] column never renders the raw token.</summary>
public sealed class DefaultTaskIdEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
if (!logEvent.Properties.ContainsKey("TaskId"))
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TaskId", "-"));
}
}

View File

@@ -0,0 +1,44 @@
using Serilog;
using Serilog.Events;
namespace ClaudeDo.Logging;
public static class LoggingSetup
{
private const string OutputTemplate =
"[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Process}/{SourceContext} [{TaskId}] {Message:lj}{NewLine}{Exception}";
public static LoggerConfiguration Configure(LoggerConfiguration cfg, string processTag, string logRoot)
{
Directory.CreateDirectory(logRoot);
var logFile = Path.Combine(logRoot, "claudedo-.log");
cfg.Enrich.FromLogContext()
.Enrich.WithProperty("Process", processTag)
.Enrich.With(new DefaultTaskIdEnricher());
if (BuildConfig.IsDebug)
{
cfg.MinimumLevel.Debug()
.WriteTo.Console(outputTemplate: OutputTemplate)
.WriteTo.File(
logFile,
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 2,
shared: true,
outputTemplate: OutputTemplate);
}
else
{
cfg.MinimumLevel.Warning()
.WriteTo.File(
logFile,
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 2,
shared: true,
outputTemplate: OutputTemplate);
}
return cfg;
}
}

View File

@@ -0,0 +1,15 @@
namespace ClaudeDo.Releases;
public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version);
public enum SelfUpdateDecisionKind
{
NoUpdate,
UpdateAvailable,
}
public sealed record SelfUpdateDecision(
SelfUpdateDecisionKind Kind,
string? LatestVersion = null,
ReleaseAsset? InstallerAsset = null,
ReleaseAsset? ChecksumsAsset = null);

View File

@@ -0,0 +1,126 @@
using System.IO;
using System.Net.Http;
using System.Text.RegularExpressions;
namespace ClaudeDo.Releases;
public static partial class SelfUpdater
{
[GeneratedRegex(@"^ClaudeDo\.Installer-(?<version>[\d\.]+)\.exe$", RegexOptions.IgnoreCase)]
private static partial Regex InstallerAssetRegex();
public static InstallerAssetMatch? FindInstallerAsset(IEnumerable<ReleaseAsset> assets)
{
foreach (var asset in assets)
{
var m = InstallerAssetRegex().Match(asset.Name);
if (m.Success)
{
return new InstallerAssetMatch(asset, m.Groups["version"].Value);
}
}
return null;
}
public static async Task<SelfUpdateDecision> DecideUpdateAsync(
IReleaseClient releases,
string currentVersion,
CancellationToken ct)
{
GiteaRelease? release;
try
{
release = await releases.GetLatestReleaseAsync(ct);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
}
if (release is null)
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
var match = FindInstallerAsset(release.Assets);
if (match is null)
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
var cmp = VersionComparer.Compare(match.Version, currentVersion);
if (!cmp.IsNewer)
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
var checksums = release.Assets.FirstOrDefault(
a => string.Equals(a.Name, "checksums.txt", StringComparison.OrdinalIgnoreCase));
return new SelfUpdateDecision(
SelfUpdateDecisionKind.UpdateAvailable,
LatestVersion: match.Version,
InstallerAsset: match.Asset,
ChecksumsAsset: checksums);
}
public static async Task<bool> HandleReplaceSelfAsync(
string oldPath,
string currentExePath,
Func<string, bool> launchProcess,
int maxWaitMs = 5000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(maxWaitMs);
while (DateTime.UtcNow < deadline)
{
try
{
if (File.Exists(oldPath))
{
File.Delete(oldPath);
}
break;
}
catch (IOException)
{
await Task.Delay(100);
}
catch (UnauthorizedAccessException)
{
await Task.Delay(100);
}
}
if (File.Exists(oldPath))
{
return false;
}
File.Copy(currentExePath, oldPath, overwrite: false);
return launchProcess(oldPath);
}
public static async Task<string?> DownloadAndVerifyAsync(
IReleaseClient releases,
ReleaseAsset installerAsset,
ReleaseAsset checksumsAsset,
string tempDir,
IProgress<long> progress,
CancellationToken ct)
{
Directory.CreateDirectory(tempDir);
var installerPath = Path.Combine(tempDir, installerAsset.Name);
var checksumsPath = Path.Combine(tempDir, "checksums.txt");
try
{
await releases.DownloadAsync(installerAsset.BrowserDownloadUrl, installerPath, progress, ct);
await releases.DownloadAsync(checksumsAsset.BrowserDownloadUrl, checksumsPath, new Progress<long>(_ => { }), ct);
}
catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException)
{
return null;
}
var checksumsText = await File.ReadAllTextAsync(checksumsPath, ct);
var map = ChecksumVerifier.ParseChecksumsFile(checksumsText);
if (!map.TryGetValue(installerAsset.Name, out var expected))
return null;
return ChecksumVerifier.Verify(installerPath, expected) ? installerPath : null;
}
}

View File

@@ -8,62 +8,56 @@ MVVM with CommunityToolkit.Mvvm source generators:
- `[ObservableProperty]` for bindable properties - `[ObservableProperty]` for bindable properties
- `[RelayCommand]` for commands (supports async and CanExecute) - `[RelayCommand]` for commands (supports async and CanExecute)
- All ViewModels inherit `ViewModelBase` (extends `ObservableObject`) - All ViewModels inherit `ViewModelBase` (extends `ObservableObject`)
- All views use compiled bindings (`x:DataType`)
## Layout: Islands ## Views
`MainWindow` hosts three "islands" (lists | tasks | details). There is no MainWindowViewModel, StatusBarView, or task/list editor modal — the root coordinator is **IslandsShellViewModel**, and task/list editing happens inline in the islands. - **MainWindow** — 3-column DockPanel layout (lists | tasks | detail) with GridSplitter, status bar at bottom
- **TaskListView** — ListBox of tasks with add/edit/delete toolbar
- **TaskDetailView** — Task info, live log output, worktree section (merge/keep/discard)
- **TaskEditorView** — Modal dialog for task create/edit
- **ListEditorView** — Modal dialog for list create/edit
- **StatusBarView** — Connection status indicator, active task display
- **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath/MaxTurns, each showing the inherited (global) value with a source-aware "inherited · Global / override" badge and a reset-to-inherited button; also deletes the list (and its tasks) via a confirmed "Delete list" button. Opened via context menu or gear button on a list row.
- **RepoImportModalView** — bulk-creates lists from git repos discovered under chosen parent folders. Opened via the folder button beside "New list" in the Lists island, or the "Add repos as lists…" Help-menu item. Repos already wired to a list show as disabled/"(already added)".
- **DetailsIslandView** — contains an "Agent settings (overrides)" expander with per-task Model/MaxTurns/AgentPath (override semantics, each showing the resolved inherited value as a placeholder plus a source-aware "inherited · List / inherited · Global / override" badge and reset button via the reusable `InheritedBadge` control + `InheritanceResolver`) and a SystemPrompt text box (additive — shows the inherited prompt as a "prepended automatically" note). Disabled while task is running. When notes mode is active (`IsNotesMode`), it hosts **NotesEditorView** instead of the task detail. When prep mode is active (`IsPrepMode`), it hosts the daily-prep panel (Plan day button, empty-state hint, embedded **SessionTerminalView**). The task header, metadata footer (delete/close), and **AgentStripView** are gated on `IsTaskDetailVisible` — they are hidden in both notes and prep mode.
- **WeeklyReportModalView** — opened from Help menu ("Wochenbericht…"); date-range pickers default to "since last standup weekday → today"; Generate/Regenerate button; renders markdown via MarkdownView; reports are cached per range.
- **NotesEditorView** — day navigator (prev/next/date-picker/Today), bullet add/edit/delete for daily notes.
- **SessionTerminalView** — reusable log terminal; exposes StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`. Used for both the task `Log` and the prep `PrepLog`.
- **SettingsModalView** — Prime Claude tab contains a `DailyPrepMaxTasks` numeric editor.
- **TasksIslandView** (MyDay header) — icon buttons visible only when `IsMyDayList`: broom icon = `ClearDayCommand`, stroked-sun icon ("Plan My Day") = `ShowPrepLogCommand`.
``` All views use compiled bindings (`x:DataType`).
ViewModels/
IslandsShellViewModel.cs — root coordinator
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
NotesEditor, MergePreviewPresenter
Agent/ — AgentConfigEditorViewModel (scope-parameterized: List | Task)
Modals/ — About, DiffViewer (+ DiffModels), ListSettings, Merge, RepoImport,
Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport,
WorkerConnection, WorktreesOverview, UnifiedDiffParser
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge, AgentConfigEditor
Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyles.axaml
(component styles + the filled icon geometry library)
```
## ViewModels ## ViewModels
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay via `OpenLogVisualizerCommand`; `FlashFooterError` surfaces UI-action failures + the worker's Serilog Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help, LogVisualizer) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`. - **MainWindowViewModel** — root coordinator; manages list collection, selected list, dialog creation via `Func<T>` factories
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`. - **TaskListViewModel** — manages task collection for selected list; handles CRUD, "Run Now"
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RunInteractivelyAsync`, `RefineTask`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell. - **TaskDetailViewModel** — displays task details, streams live log, controls worktree operations
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel` and call `ShowDiffViewer`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand``RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file. - **TaskItemViewModel** / **ListItemViewModel** — lightweight display VMs
- **TaskRowViewModel** / **ListNavItemViewModel**lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints). - **TaskEditorViewModel** / **ListEditorViewModel**dialog VMs with validation
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`. - **StatusBarViewModel** — connection state and active tasks
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`). - **WeeklyReportModalViewModel** — drives the weekly report modal
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders. - **NotesEditorViewModel** — manages daily note bullet CRUD for the selected day
- **Conflicts** — `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — ``/`` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`). - **DetailsIslandViewModel** gains `IsNotesMode`, `ShowNotes()`, and hosts `NotesEditorViewModel`. Also gains daily-prep mode: `IsPrepMode`, `PrepLog` (`ObservableCollection<LogLineViewModel>`), `ShowPrep()`, `PlanDayCommand` (calls `RunDailyPrepNowAsync`), `ShowPrepEmptyState`, and computed `IsTaskDetailVisible` (= `!IsNotesMode && !IsPrepMode`). Subscribes to `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`; streams lines into `PrepLog` via `StreamLineFormatter` (same path as task logs). On open, if log is empty and no run is in progress, loads persisted last run via `GetLastPrepLogAsync`.
- **TasksIslandViewModel** gains a pinned "Notes" pseudo-row (`ShowNotesRow`, `OpenNotesCommand`, `NotesRequested` event) that switches the Details island to notes mode. Also gains `IsMyDayList` (true when selected list is `smart:my-day`), `ShowPrepLogCommand` (raises `PrepRequested` event → shell calls `Details.ShowPrep()`), and `ClearDayCommand` (calls `ClearMyDayAsync`).
## Services ## Services
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, interactive terminal, refine), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-conflict-documents/write-resolution/continue/abort), worktrees (overview, set state, force remove, cleanup, reset all), agents, app settings, lists/config, weekly report, daily notes, daily prep (`RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`), prime schedules, recent worker logs (`GetRecentLogsAsync`). Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`. - **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync, GetAgentsAsync, UpdateAppSettingsAsync, UpdateListAsync, UpdateListConfigAsync, GetListConfigAsync, UpdateTaskAgentSettingsAsync, `GetWeekReportAsync`, `GenerateWeekReportAsync`, `GetDailyNotesAsync`, `AddDailyNoteAsync`, `UpdateDailyNoteAsync`, `DeleteDailyNoteAsync`, `RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated, ListUpdated, `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`
- **INotesApi** / **WorkerNotesApi**daily-note CRUD (`ListAsync(day)`, `AddAsync`, `UpdateAsync`, `DeleteAsync`); UI DTO `DailyNoteDto(Id, Date, Text, SortOrder)`. - **INotesApi** / **WorkerNotesApi**thin wrapper over the daily-note WorkerClient methods (mirrors `IPrimeScheduleApi`). UI DTO: `DailyNoteDto(Id, Date, Text, SortOrder)`.
- **IPrimeScheduleApi** — prime-schedule CRUD (`ListAsync`, `UpsertAsync`, `DeleteAsync`).
- **UpdateCheckService** — polls releases, exposes `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` (feeds the shell's update banner).
- **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges.
- **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**.
## Converters ## Converters
`StatusColorConverter` (+ `ConnectionColorConverter` in the same file), `WorkerLogLevelToBrushConverter`, `DotBrushConverter`, `EqStatusConverter`, `IconKeyConverter`, `CheckboxBorderConverter`, `StrikeIfTrueConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter`, `NotNullToBoolConverter`, `UpperCaseConverter`, `DateOnlyToDateTimeConverter`. - **StatusColorConverter** — task status string -> color (Queued=Blue, Running=Orange, Done=Green, Failed=Red, Manual=Gray)
- **ConnectionColorConverter** — connection state -> color (Online=Green, Offline=Red)
## Dialog Pattern ## Dialog Pattern
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog sets the result on save/cancel, and the caller awaits the TCS. Editor dialogs use `TaskCompletionSource<bool>` — the dialog sets the result on save/cancel, and the caller awaits the TCS.
## Notes ## Notes
- Context menus exist on both list rows and task rows; right-click selects before opening the menu - Context menus are on both list items and task items
- Right-click selects the item before showing the context menu
- "Run Now" CanExecute re-evaluates when worker connection state changes - "Run Now" CanExecute re-evaluates when worker connection state changes
- Icon gotcha: `PathIcon` fills geometry. Line-art/stroke icons must be defined as filled geometry or rendered as a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style); a pure stroke path used with `PathIcon` is invisible. - Icon gotcha: `PathIcon` fills geometry. Line-art/stroke icons must be defined as filled geometry or rendered as a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style); a pure stroke path used with `PathIcon` is invisible.
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) used for both the task `Log` and the prep `PrepLog`.
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" hover overlay. `DescriptionStepsCard` shows an Attachments list (file name, size, remove button), an "Add file…" picker, and an explicit `DropStatus` confirmation line. Keys use the `details.attachments.*` localization namespace (en + de).

View File

@@ -7,15 +7,13 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.4" /> <PackageReference Include="Avalonia" Version="12.0.0" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" />
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" /> <PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" /> <PackageReference Include="Serilog" Version="4.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,30 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Converters;
public sealed class DiffLineKindToBrushConverter : IValueConverter
{
private static readonly ISolidColorBrush Added = new SolidColorBrush(Color.Parse("#66BB6A"));
private static readonly ISolidColorBrush Removed = new SolidColorBrush(Color.Parse("#EF5350"));
private static readonly ISolidColorBrush Hunk = new SolidColorBrush(Color.Parse("#42A5F5"));
private static readonly ISolidColorBrush Header = new SolidColorBrush(Color.Parse("#9E9E9E"));
private static readonly ISolidColorBrush Default = new SolidColorBrush(Color.Parse("#CFD8DC"));
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is WorktreeDiffLineKind kind
? kind switch
{
WorktreeDiffLineKind.Added => Added,
WorktreeDiffLineKind.Removed => Removed,
WorktreeDiffLineKind.Hunk => Hunk,
WorktreeDiffLineKind.Header => Header,
_ => Default,
}
: Default;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -1,43 +0,0 @@
using System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
using Avalonia.Media;
using Avalonia.Styling;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Converters;
public sealed class LogKindForegroundConverter : IValueConverter
{
private static IBrush? Resolve(string key)
{
if (Application.Current is { } app &&
app.Resources.TryGetResource(key, app.ActualThemeVariant, out var res) &&
res is IBrush brush)
{
return brush;
}
return null;
}
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var key = value is LogKind kind ? kind switch
{
LogKind.Sys => "TextMuteBrush",
LogKind.Tool => "SageBrush",
LogKind.Claude => "TextBrush",
LogKind.Stdout => "TextDimBrush",
LogKind.Stderr => "BloodBrush",
LogKind.Done => "MossBrightBrush",
LogKind.Msg => "TextDimBrush",
LogKind.User => "AccentBrush",
_ => "TextDimBrush",
} : "TextDimBrush";
return Resolve(key) ?? AvaloniaProperty.UnsetValue;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}

View File

@@ -0,0 +1,30 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Ui.Converters;
public sealed class WorktreeStateColorConverter : IValueConverter
{
private static readonly ISolidColorBrush Active = new SolidColorBrush(Color.Parse("#42A5F5"));
private static readonly ISolidColorBrush Merged = new SolidColorBrush(Color.Parse("#66BB6A"));
private static readonly ISolidColorBrush Discarded = new SolidColorBrush(Color.Parse("#9E9E9E"));
private static readonly ISolidColorBrush Kept = new SolidColorBrush(Color.Parse("#FFA726"));
private static readonly ISolidColorBrush Default = new SolidColorBrush(Colors.Gray);
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is WorktreeState state
? state switch
{
WorktreeState.Active => Active,
WorktreeState.Merged => Merged,
WorktreeState.Discarded => Discarded,
WorktreeState.Kept => Kept,
_ => Default,
}
: Default;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -21,8 +21,6 @@
<!-- Window control icons — filled geometries (PathIcon fills, not strokes) --> <!-- Window control icons — filled geometries (PathIcon fills, not strokes) -->
<StreamGeometry x:Key="Icon.WinMin">M4 9 H16 V11 H4 Z</StreamGeometry> <StreamGeometry x:Key="Icon.WinMin">M4 9 H16 V11 H4 Z</StreamGeometry>
<StreamGeometry x:Key="Icon.WinMax">M4 4 H16 V6 H4 Z M4 14 H16 V16 H4 Z M4 4 H6 V16 H4 Z M14 4 H16 V16 H14 Z</StreamGeometry> <StreamGeometry x:Key="Icon.WinMax">M4 4 H16 V6 H4 Z M4 14 H16 V16 H4 Z M4 4 H6 V16 H4 Z M14 4 H16 V16 H14 Z</StreamGeometry>
<!-- Icon.Grid (four filled panes — Mission Control launcher) -->
<StreamGeometry x:Key="Icon.Grid">M3 3 H9 V9 H3 Z M11 3 H17 V9 H11 Z M3 11 H9 V17 H3 Z M11 11 H17 V17 H11 Z</StreamGeometry>
<StreamGeometry x:Key="Icon.WinRestore">M4 7 H13 V9 H4 Z M4 14 H13 V16 H4 Z M4 7 H6 V16 H4 Z M11 7 H13 V16 H11 Z M7 4 H16 V6 H7 Z M14 4 H16 V13 H14 Z</StreamGeometry> <StreamGeometry x:Key="Icon.WinRestore">M4 7 H13 V9 H4 Z M4 14 H13 V16 H4 Z M4 7 H6 V16 H4 Z M11 7 H13 V16 H11 Z M7 4 H16 V6 H7 Z M14 4 H16 V13 H14 Z</StreamGeometry>
<StreamGeometry x:Key="Icon.WinClose">M4 5 L5 4 L16 15 L15 16 Z M15 4 L16 5 L5 16 L4 15 Z</StreamGeometry> <StreamGeometry x:Key="Icon.WinClose">M4 5 L5 4 L16 15 L15 16 Z M15 4 L16 5 L5 16 L4 15 Z</StreamGeometry>
<!-- Brand check glyph — filled rounded square with inset tick --> <!-- Brand check glyph — filled rounded square with inset tick -->
@@ -78,14 +76,8 @@
<!-- Icon.PlanDay (stroke-rendered via Path.plan-icon — sun over horizon) --> <!-- Icon.PlanDay (stroke-rendered via Path.plan-icon — sun over horizon) -->
<StreamGeometry x:Key="Icon.PlanDay">M3,20 L21,20 M8.4,11 a3.6,3.6 0 1,0 7.2,0 a3.6,3.6 0 1,0 -7.2,0 M12,4.5 L12,3 M6,11 L4.5,11 M18,11 L19.5,11 M7.5,6.5 L6.4,5.4 M16.5,6.5 L17.6,5.4</StreamGeometry> <StreamGeometry x:Key="Icon.PlanDay">M3,20 L21,20 M8.4,11 a3.6,3.6 0 1,0 7.2,0 a3.6,3.6 0 1,0 -7.2,0 M12,4.5 L12,3 M6,11 L4.5,11 M18,11 L19.5,11 M7.5,6.5 L6.4,5.4 M16.5,6.5 L17.6,5.4</StreamGeometry>
<!-- Icon.Refine (stroke-rendered via Path.plan-icon — list lines + two sparkles) --> <!-- Icon.X -->
<StreamGeometry x:Key="Icon.Refine">M3,6 L13,6 M3,11 L11,11 M3,16 L9,16 M18.5,3 L19.28,5.22 L21.5,6 L19.28,6.78 L18.5,9 L17.72,6.78 L15.5,6 L17.72,5.22 Z M19.5,14.9 L19.85,16.15 L21.1,16.5 L19.85,16.85 L19.5,18.1 L19.15,16.85 L17.9,16.5 L19.15,16.15 Z</StreamGeometry> <StreamGeometry x:Key="Icon.X">M6 6l12 12M18 6L6 18</StreamGeometry>
<!-- Icon.X — filled X outline (PathIcon fills, so a stroke-only X renders invisible) -->
<StreamGeometry x:Key="Icon.X">M6.4 4.6 L12 10.2 L17.6 4.6 L19.4 6.4 L13.8 12 L19.4 17.6 L17.6 19.4 L12 13.8 L6.4 19.4 L4.6 17.6 L10.2 12 L4.6 6.4 Z</StreamGeometry>
<!-- Icon.Stop — filled square (stop / interrupt) -->
<StreamGeometry x:Key="Icon.Stop">M4 4 H20 V20 H4 Z</StreamGeometry>
<!-- Icon.Check --> <!-- Icon.Check -->
<StreamGeometry x:Key="Icon.Check">M4 12l5 5 11-11</StreamGeometry> <StreamGeometry x:Key="Icon.Check">M4 12l5 5 11-11</StreamGeometry>
@@ -93,13 +85,6 @@
<!-- Icon.ArrowOut — filled arrow for "open external" button --> <!-- Icon.ArrowOut — filled arrow for "open external" button -->
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry> <StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
<!-- Icon.ChevronRight / Icon.ChevronDown — filled expand/collapse chevrons (PathIcon fills) -->
<StreamGeometry x:Key="Icon.ChevronRight">M9 5 L16 12 L9 19 L6.8 16.8 L11.6 12 L6.8 7.2 Z</StreamGeometry>
<StreamGeometry x:Key="Icon.ChevronDown">M5 9 L12 16 L19 9 L16.8 6.8 L12 11.6 L7.2 6.8 Z</StreamGeometry>
<!-- Icon.Text — three filled horizontal bars (paragraph / description icon) -->
<StreamGeometry x:Key="Icon.Text">M4 6 H20 V8 H4 Z M4 11 H20 V13 H4 Z M4 16 H14 V18 H4 Z</StreamGeometry>
<!-- Icon.Warning — filled triangle with exclamation (roadblock badge) --> <!-- Icon.Warning — filled triangle with exclamation (roadblock badge) -->
<StreamGeometry x:Key="Icon.Warning">F0 M12 3 L22 20 H2 Z M11 9 H13 V14 H11 Z M11 16 H13 V18 H11 Z</StreamGeometry> <StreamGeometry x:Key="Icon.Warning">F0 M12 3 L22 20 H2 Z M11 9 H13 V14 H11 Z M11 16 H13 V18 H11 Z</StreamGeometry>
@@ -109,9 +94,6 @@
<!-- Icon.Settings (gear) --> <!-- Icon.Settings (gear) -->
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry> <StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
<!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw -->
<StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry>
<!-- Badge brushes --> <!-- Badge brushes -->
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/> <SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/> <SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
@@ -238,52 +220,6 @@
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" /> <Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style> </Style>
<!-- parked → slate-blue: an Idle task still holding its Active worktree -->
<Style Selector="Border.chip.parked">
<Setter Property="Background" Value="#22303A" />
<Setter Property="BorderBrush" Value="#3A5060" />
</Style>
<Style Selector="Border.chip.parked > TextBlock">
<Setter Property="Foreground" Value="#8FB9D6" />
</Style>
<!-- Worktree-state chips (worktrees overview) -->
<!-- active → slate-blue (same hue as parked: a live worktree) -->
<Style Selector="Border.chip.wt-active">
<Setter Property="Background" Value="#22303A" />
<Setter Property="BorderBrush" Value="#3A5060" />
</Style>
<Style Selector="Border.chip.wt-active > TextBlock">
<Setter Property="Foreground" Value="#8FB9D6" />
</Style>
<!-- merged → green -->
<Style Selector="Border.chip.wt-merged">
<Setter Property="Background" Value="{StaticResource DoneTintBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource DoneTintBorderBrush}" />
</Style>
<Style Selector="Border.chip.wt-merged > TextBlock">
<Setter Property="Foreground" Value="{StaticResource StatusDoneBrush}" />
</Style>
<!-- kept → amber -->
<Style Selector="Border.chip.wt-kept">
<Setter Property="Background" Value="{StaticResource ReviewTintBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource ReviewTintBorderBrush}" />
</Style>
<Style Selector="Border.chip.wt-kept > TextBlock">
<Setter Property="Foreground" Value="{StaticResource StatusReviewBrush}" />
</Style>
<!-- discarded → muted gray (same as idle) -->
<Style Selector="Border.chip.wt-discarded">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
</Style>
<Style Selector="Border.chip.wt-discarded > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- BUTTONS --> <!-- BUTTONS -->
<!-- ============================================================ --> <!-- ============================================================ -->
@@ -410,8 +346,6 @@
<BrushTransition Property="Background" Duration="0:0:0.12" /> <BrushTransition Property="Background" Duration="0:0:0.12" />
<BrushTransition Property="BorderBrush" Duration="0:0:0.12" /> <BrushTransition Property="BorderBrush" Duration="0:0:0.12" />
<ThicknessTransition Property="Margin" Duration="0:0:0.15" /> <ThicknessTransition Property="Margin" Duration="0:0:0.15" />
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
<DoubleTransition Property="Opacity" Duration="0:0:0.12" />
</Transitions> </Transitions>
</Setter> </Setter>
</Style> </Style>
@@ -419,16 +353,9 @@
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" /> <Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
</Style> </Style>
<Style Selector="Border.task-row.selected"> <Style Selector="Border.task-row.selected">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" /> <Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="1" />
</Style> </Style>
<!-- "Grabbed" row: lift + slight scale + lower opacity + shadow while the custom drag runs. -->
<Style Selector="Border.task-row.dragging">
<Setter Property="Opacity" Value="0.55" />
<Setter Property="RenderTransform" Value="scale(1.03)" />
<Setter Property="BoxShadow" Value="0 10 26 0 #66000000" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Style>
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) --> <!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
<Style Selector="Ellipse.task-check"> <Style Selector="Ellipse.task-check">
@@ -531,10 +458,6 @@
<Style Selector="Border.terminal TextBlock[Tag=log-msg]"> <Style Selector="Border.terminal TextBlock[Tag=log-msg]">
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" /> <Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
</Style> </Style>
<!-- log-user: user's own messages in interactive sessions — accent color to stand out -->
<Style Selector="Border.terminal TextBlock[Tag=log-user]">
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
</Style>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- TERMINAL HEADER --> <!-- TERMINAL HEADER -->
@@ -642,13 +565,6 @@
<Style Selector="Border[Tag=?] > TextBlock"> <Style Selector="Border[Tag=?] > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/> <Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
</Style> </Style>
<!-- R → rename (sage) -->
<Style Selector="Border[Tag=R]">
<Setter Property="Background" Value="#268B9D7A"/>
</Style>
<Style Selector="Border[Tag=R] > TextBlock">
<Setter Property="Foreground" Value="{StaticResource SageBrush}"/>
</Style>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- LIST NAV ITEM --> <!-- LIST NAV ITEM -->
@@ -939,9 +855,14 @@
<Setter Property="Padding" Value="8,5" /> <Setter Property="Padding" Value="8,5" />
<Setter Property="CornerRadius" Value="6" /> <Setter Property="CornerRadius" Value="6" />
<Setter Property="Background" Value="Transparent" /> <Setter Property="Background" Value="Transparent" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.10"/>
</Transitions>
</Setter>
</Style> </Style>
<Style Selector="Border.subtask-row:pointerover"> <Style Selector="Border.subtask-row:pointerover">
<Setter Property="Background" Value="{StaticResource Surface3Brush}" /> <Setter Property="Background" Value="{StaticResource Surface2Brush}" />
</Style> </Style>
<Style Selector="Border.subtask-row.done TextBlock.subtask-title"> <Style Selector="Border.subtask-row.done TextBlock.subtask-title">
<Setter Property="Opacity" Value="0.5" /> <Setter Property="Opacity" Value="0.5" />
@@ -1145,23 +1066,6 @@
<Setter Property="Foreground" Value="{StaticResource TextBrush}" /> <Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
</Style> </Style>
<!-- Override Fluent's built-in accent button (SystemAccentColor = blue) at the
ContentPresenter level so our moss tokens win across rest/hover/pressed. -->
<Style Selector="Button.accent /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentDimBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="Button.accent:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="Button.accent:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- DAY TOGGLE --> <!-- DAY TOGGLE -->
@@ -1182,30 +1086,4 @@
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/> <Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
</Style> </Style>
<!-- ============================================================ -->
<!-- MISSION CONTROL PANE STATUS TINTING -->
<!-- Base neutral grey; tints layer on by status. -->
<!-- Running / idle / queued: no class → fall through to base. -->
<!-- ============================================================ -->
<Style Selector="Border.monitor-pane">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
</Style>
<Style Selector="Border.monitor-pane.mon-done">
<Setter Property="Background" Value="{DynamicResource DoneTintBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource DoneTintBorderBrush}" />
</Style>
<Style Selector="Border.monitor-pane.mon-review">
<Setter Property="Background" Value="{DynamicResource DoneTintBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource DoneTintBorderBrush}" />
</Style>
<Style Selector="Border.monitor-pane.mon-roadblock">
<Setter Property="Background" Value="{DynamicResource RoadblockTintBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource RoadblockTintBorderBrush}" />
</Style>
<Style Selector="Border.monitor-pane.mon-failed">
<Setter Property="Background" Value="{DynamicResource ErrorTintBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource ErrorTintBorderBrush}" />
</Style>
</Styles> </Styles>

View File

@@ -99,17 +99,6 @@
<SolidColorBrush x:Key="QueuedTintBorderBrush" Color="#4C8B9D7A" /> <SolidColorBrush x:Key="QueuedTintBorderBrush" Color="#4C8B9D7A" />
<SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" /> <SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" />
<SolidColorBrush x:Key="DoneTintBorderBrush" Color="#4C6FA86B" /> <SolidColorBrush x:Key="DoneTintBorderBrush" Color="#4C6FA86B" />
<SolidColorBrush x:Key="RoadblockTintBrush" Color="#1FD4A574" />
<SolidColorBrush x:Key="RoadblockTintBorderBrush" Color="#4CD4A574" />
<!-- Merge editor (3-pane conflict resolver) block tints -->
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
<SolidColorBrush x:Key="MergeConflictTintBrush" Color="#28C87060" /> <!-- unresolved conflict (blood) -->
<SolidColorBrush x:Key="MergeConflictEdgeBrush" Color="#80C87060" /> <!-- unresolved conflict gutter edge / map tick -->
<SolidColorBrush x:Key="MergeResolvedTintBrush" Color="#206FA86B" /> <!-- resolved conflict (green) -->
<SolidColorBrush x:Key="MergeResolvedEdgeBrush" Color="#806FA86B" /> <!-- resolved conflict map tick -->
<SolidColorBrush x:Key="AmberBrush" Color="#FFD4A574" /> <!-- solid amber (theirs label) -->
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) --> <!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%"> <LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">

View File

@@ -1,40 +0,0 @@
using System;
using System.Threading.Tasks;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Conflicts;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Services;
/// <summary>
/// Single seam for opening modal dialogs. Replaces the per-modal <c>Show*Modal</c>
/// Func callbacks that were previously wired separately on the shell and the lists
/// island (and the Confirm/Error dialogs duplicated in both code-behinds). The view
/// layer supplies the implementation (<see cref="ClaudeDo.Ui.Views.WindowDialogService"/>);
/// callers build + initialize the VM and hand it here to be shown.
/// </summary>
public interface IDialogService
{
Task ShowAboutAsync(AboutModalViewModel vm);
Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm);
Task ShowSettingsAsync(SettingsModalViewModel vm);
Task ShowListSettingsAsync(ListSettingsModalViewModel vm);
Task ShowRepoImportAsync(RepoImportModalViewModel vm);
Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm);
Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm);
Task ShowConflictResolverAsync(ConflictResolverViewModel vm);
Task ShowLogVisualizerAsync(LogVisualizerViewModel vm);
/// <summary>Modal yes/no confirmation. Returns true only when confirmed.</summary>
Task<bool> ConfirmAsync(string message);
/// <summary>Modal error notice with a single dismiss button.</summary>
Task ShowErrorAsync(string message);
/// <summary>Show (or re-show + focus) the modeless Mission Control window. Lazily created; hides on close.</summary>
void ShowMissionControl(MissionControlViewModel vm);
/// <summary>Show a detached monitor in its own window; <paramref name="onClosed"/> re-docks it when that window closes.</summary>
void ShowDetachedMonitor(TaskMonitorViewModel monitor, Action onClosed);
}

View File

@@ -1,29 +0,0 @@
using System;
using System.Threading.Tasks;
namespace ClaudeDo.Ui.Services;
/// <summary>
/// Single entry point for handing a conflicting merge to the in-app 3-pane resolver.
/// Replaces the per-VM <c>RequestConflictResolution</c> Func seams that used to be
/// hand-threaded shell → details → merge-section → diff → merge-modal. The shell wires
/// <see cref="MergeCoordinator.Handler"/> once at composition; invokers depend only on
/// this interface (injected via DI).
/// </summary>
public interface IMergeCoordinator
{
Task ResolveConflictAsync(string taskId, string targetBranch);
}
/// <summary>
/// DI singleton holding the resolver entry. The holder breaks the shell↔island construction
/// cycle: islands depend on the interface, the shell sets <see cref="Handler"/> after it is built.
/// </summary>
public sealed class MergeCoordinator : IMergeCoordinator
{
/// Set once at composition to the shell's resolver entry. Null (headless/tests) ⇒ no-op.
public Func<string, string, Task>? Handler { get; set; }
public Task ResolveConflictAsync(string taskId, string targetBranch) =>
Handler?.Invoke(taskId, targetBranch) ?? Task.CompletedTask;
}

View File

@@ -1,10 +0,0 @@
namespace ClaudeDo.Ui.Services;
public sealed record OnlineLoginResult(bool Success, string? RefreshToken, string? Error, string? Warning = null);
public interface IOnlineLoginService
{
Task<OnlineLoginResult> LoginAsync(
string authority, string clientId, string scope, string redirectUri,
CancellationToken ct = default);
}

View File

@@ -8,7 +8,6 @@ namespace ClaudeDo.Ui.Services;
public interface IWorkerClient : INotifyPropertyChanged public interface IWorkerClient : INotifyPropertyChanged
{ {
bool IsConnected { get; } bool IsConnected { get; }
bool IsReconnecting { get; }
event Action<string, string, DateTime>? TaskStartedEvent; event Action<string, string, DateTime>? TaskStartedEvent;
event Action<string, string, string, DateTime>? TaskFinishedEvent; event Action<string, string, string, DateTime>? TaskFinishedEvent;
@@ -18,17 +17,6 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string>? WorktreeUpdatedEvent; event Action<string>? WorktreeUpdatedEvent;
event Action<string>? ListUpdatedEvent; event Action<string>? ListUpdatedEvent;
event Action<string, string>? TaskMessageEvent; event Action<string, string>? TaskMessageEvent;
event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
/// <summary>A running task raised a question via AskUser: (taskId, questionId, question).</summary>
event Action<string, string, string>? TaskQuestionAskedEvent;
/// <summary>A pending question was answered, timed out, or the run ended: (taskId, questionId).</summary>
event Action<string, string>? TaskQuestionResolvedEvent;
event Action<string>? InteractiveSessionStartedEvent;
event Action<string>? InteractiveSessionEndedEvent;
event Action<string, IReadOnlyList<string>>? InteractiveQueueChangedEvent;
event Action<string, string>? InteractiveMessageSentEvent;
event Action? PrepStartedEvent; event Action? PrepStartedEvent;
event Action<string>? PrepLineEvent; event Action<string>? PrepLineEvent;
@@ -40,44 +28,19 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string>? PlanningMergeAbortedEvent; event Action<string>? PlanningMergeAbortedEvent;
event Action<string>? PlanningCompletedEvent; event Action<string>? PlanningCompletedEvent;
event Action<PrimeFiredEvent>? PrimeFired;
string? LastApproveTarget { get; }
IReadOnlyList<ActiveTask> GetActiveTasks();
Task WakeQueueAsync(); Task WakeQueueAsync();
Task RunNowAsync(string taskId); Task RunNowAsync(string taskId);
Task ContinueTaskAsync(string taskId, string followUpPrompt); Task ContinueTaskAsync(string taskId, string followUpPrompt);
/// <summary>Answer a question a running task raised via AskUser.</summary>
Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer);
Task SendInteractiveMessageAsync(string taskId, string text);
Task RemoveQueuedInteractiveMessageAsync(string taskId, string text);
Task StopInteractiveSessionAsync(string taskId);
Task InterruptInteractiveSessionAsync(string taskId);
/// <summary>The question a running task is currently blocked on, if any (for re-attach).</summary>
Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId);
Task ResetTaskAsync(string taskId); Task ResetTaskAsync(string taskId);
Task CancelTaskAsync(string taskId); Task CancelTaskAsync(string taskId);
Task<List<AgentInfo>> GetAgentsAsync(); Task<List<AgentInfo>> GetAgentsAsync();
Task RefreshAgentsAsync();
Task<SeedResultDto?> RestoreDefaultAgentsAsync();
Task<ListConfigDto?> GetListConfigAsync(string listId); Task<ListConfigDto?> GetListConfigAsync(string listId);
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto); Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
Task SetTaskStatusAsync(string taskId, TaskStatus status); Task SetTaskStatusAsync(string taskId, TaskStatus status);
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch); Task ApproveReviewAsync(string taskId);
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
Task RejectReviewToQueueAsync(string taskId, string feedback); Task RejectReviewToQueueAsync(string taskId, string feedback);
Task RejectReviewToIdleAsync(string taskId); Task RejectReviewToIdleAsync(string taskId);
Task CancelReviewAsync(string taskId); Task CancelReviewAsync(string taskId);
// ── Conflict resolution (worker hub side implemented by Layer C) ──
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId);
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
Task<MergeResultDto> ContinueConflictMergeAsync(string taskId);
Task AbortConflictMergeAsync(string taskId);
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default); Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default); Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default); Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
@@ -87,41 +50,18 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId); Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);
Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId); Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId);
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch); Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
Task MergeAllPlanningAsync(string planningTaskId, string targetBranch);
Task ContinuePlanningMergeAsync(string planningTaskId); Task ContinuePlanningMergeAsync(string planningTaskId);
Task AbortPlanningMergeAsync(string planningTaskId); Task AbortPlanningMergeAsync(string planningTaskId);
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default); Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end); Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end); Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end);
Task<bool> RunDailyPrepNowAsync(); Task<bool> RunDailyPrepNowAsync();
Task RefineTaskAsync(string taskId);
event Action<string>? RefineStartedEvent;
event Action<string, bool, string?>? RefineFinishedEvent;
Task ClearMyDayAsync(); Task ClearMyDayAsync();
Task<AppSettingsDto?> GetAppSettingsAsync(); Task<AppSettingsDto?> GetAppSettingsAsync();
Task UpdateAppSettingsAsync(AppSettingsDto dto);
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day); Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text); Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text);
Task UpdateDailyNoteAsync(string id, string text); Task UpdateDailyNoteAsync(string id, string text);
Task DeleteDailyNoteAsync(string id); Task DeleteDailyNoteAsync(string id);
Task<string> GetLastPrepLogAsync(); Task<string> GetLastPrepLogAsync();
Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync();
Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync();
Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto);
Task DeletePrimeScheduleAsync(Guid id);
Task UpdateListAsync(UpdateListDto dto);
Task UpdateListConfigAsync(UpdateListConfigDto dto);
Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null);
Task<WorktreeResetDto?> ResetAllWorktreesAsync();
Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId);
Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState);
Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId);
Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync();
Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input);
Task SetOnlineInboxAuthAsync(string refreshToken);
Task ClearOnlineInboxAuthAsync();
} }

View File

@@ -1,143 +0,0 @@
using System.Diagnostics;
using System.Net;
using System.Text;
using Duende.IdentityModel.OidcClient;
using Duende.IdentityModel.OidcClient.Browser;
namespace ClaudeDo.Ui.Services;
public sealed class OnlineLoginService : IOnlineLoginService
{
public async Task<OnlineLoginResult> LoginAsync(
string authority, string clientId, string scope, string redirectUri,
CancellationToken ct = default)
{
try
{
var browser = new LoopbackBrowser(redirectUri);
var options = new OidcClientOptions
{
Authority = authority,
ClientId = clientId,
Scope = scope,
RedirectUri = redirectUri,
Browser = browser,
};
var client = new OidcClient(options);
var result = await client.LoginAsync(new LoginRequest(), ct);
if (result.IsError)
return new OnlineLoginResult(false, null, result.Error);
if (string.IsNullOrEmpty(result.RefreshToken))
return new OnlineLoginResult(false, null,
"No refresh token returned. Ensure 'offline_access' is in scope and the client allows it.");
// Early heads-up: if the access token lacks the "user" project role the server will
// reject sync with a 401. Login still succeeds; surface this as a warning, not an error.
var warning = ZitadelTokenInspector.HasUserRole(result.AccessToken)
? null
: "missing-user-role";
return new OnlineLoginResult(true, result.RefreshToken, null, warning);
}
catch (OperationCanceledException)
{
return new OnlineLoginResult(false, null, "Login cancelled.");
}
catch (Exception ex)
{
return new OnlineLoginResult(false, null, ex.Message);
}
}
}
/// <summary>
/// IBrowser implementation: opens the system browser and captures the authorization
/// response via a loopback HttpListener on the redirect URI's host/port.
/// </summary>
sealed class LoopbackBrowser : IBrowser
{
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(3);
private readonly string _redirectUri;
public LoopbackBrowser(string redirectUri) => _redirectUri = redirectUri;
public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken ct = default)
{
// Derive the listener prefix from the redirect URI
var uri = new Uri(_redirectUri);
var prefix = $"{uri.Scheme}://{uri.Host}:{uri.Port}/";
using var listener = new HttpListener();
listener.Prefixes.Add(prefix);
try
{
listener.Start();
}
catch (Exception ex)
{
return new BrowserResult
{
ResultType = BrowserResultType.UnknownError,
Error = $"Could not start loopback listener on {prefix}: {ex.Message}"
};
}
try
{
Process.Start(new ProcessStartInfo(options.StartUrl) { UseShellExecute = true });
}
catch (Exception ex)
{
return new BrowserResult
{
ResultType = BrowserResultType.UnknownError,
Error = $"Could not open browser: {ex.Message}"
};
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(Timeout);
try
{
var context = await listener.GetContextAsync().WaitAsync(cts.Token);
var responseBody = Encoding.UTF8.GetBytes(
"<html><body style=\"font-family:sans-serif;background:#0D1311;color:#E4EBE4;padding:40px\">" +
"<h2>Login successful</h2><p>You may close this tab.</p></body></html>");
context.Response.ContentLength64 = responseBody.Length;
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.OutputStream.WriteAsync(responseBody, cts.Token);
context.Response.OutputStream.Close();
// rawUrl already includes the redirect path (e.g. "/callback?code=..."),
// so build the full URL from the scheme://host:port base — NOT the full
// redirect URI, or the path would be doubled (".../callback/callback").
var rawUrl = context.Request.RawUrl ?? "";
var fullUri = prefix.TrimEnd('/') + rawUrl;
return new BrowserResult
{
ResultType = BrowserResultType.Success,
Response = fullUri
};
}
catch (OperationCanceledException)
{
return new BrowserResult
{
ResultType = BrowserResultType.Timeout,
Error = "Login timed out waiting for browser callback."
};
}
finally
{
listener.Stop();
}
}
}

View File

@@ -1,5 +1,4 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq;
using Avalonia.Threading; using Avalonia.Threading;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
@@ -7,6 +6,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog.Context;
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
@@ -31,6 +32,7 @@ sealed class IndefiniteRetryPolicy : IRetryPolicy
public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerClient public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerClient
{ {
private readonly HubConnection _hub; private readonly HubConnection _hub;
private readonly ILogger<WorkerClient> _logger;
private CancellationTokenSource? _startCts; private CancellationTokenSource? _startCts;
private Task _retryLoopTask = Task.CompletedTask; private Task _retryLoopTask = Task.CompletedTask;
private readonly object _startLock = new(); private readonly object _startLock = new();
@@ -47,12 +49,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string, string, string, DateTime>? TaskFinishedEvent; public event Action<string, string, string, DateTime>? TaskFinishedEvent;
public event Action<string, string>? TaskMessageEvent; public event Action<string, string>? TaskMessageEvent;
public event Action<string>? TaskUpdatedEvent; public event Action<string>? TaskUpdatedEvent;
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string>? InteractiveSessionStartedEvent;
public event Action<string>? InteractiveSessionEndedEvent;
public event Action<string, IReadOnlyList<string>>? InteractiveQueueChangedEvent;
public event Action<string, string>? InteractiveMessageSentEvent;
public event Action? ConnectionRestoredEvent; public event Action? ConnectionRestoredEvent;
public event Action<string>? WorktreeUpdatedEvent; public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? ListUpdatedEvent; public event Action<string>? ListUpdatedEvent;
@@ -62,9 +58,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string>? PrepLineEvent; public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent; public event Action<bool>? PrepFinishedEvent;
public event Action<string>? RefineStartedEvent;
public event Action<string, bool, string?>? RefineFinishedEvent;
public event Action<string, string>? PlanningMergeStartedEvent; public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent; public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent; public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
@@ -73,12 +66,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<PrimeFiredEvent>? PrimeFired; public event Action<PrimeFiredEvent>? PrimeFired;
public string? LastApproveTarget { get; private set; } public string? LastMergeAllTarget { get; private set; }
public IReadOnlyList<ActiveTask> GetActiveTasks() => ActiveTasks.ToList(); public WorkerClient(string signalRUrl, ILogger<WorkerClient> logger)
public WorkerClient(string signalRUrl)
{ {
_logger = logger;
_hub = new HubConnectionBuilder() _hub = new HubConnectionBuilder()
.WithUrl(signalRUrl) .WithUrl(signalRUrl)
.WithAutomaticReconnect(new IndefiniteRetryPolicy()) .WithAutomaticReconnect(new IndefiniteRetryPolicy())
@@ -142,36 +134,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => TaskUpdatedEvent?.Invoke(taskId)); Dispatcher.UIThread.Post(() => TaskUpdatedEvent?.Invoke(taskId));
}); });
_hub.On<string, string, string>("TaskQuestionAsked", (taskId, questionId, question) =>
{
Dispatcher.UIThread.Post(() => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question));
});
_hub.On<string, string>("TaskQuestionResolved", (taskId, questionId) =>
{
Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId));
});
_hub.On<string>("InteractiveSessionStarted", taskId =>
{
Dispatcher.UIThread.Post(() => InteractiveSessionStartedEvent?.Invoke(taskId));
});
_hub.On<string>("InteractiveSessionEnded", taskId =>
{
Dispatcher.UIThread.Post(() => InteractiveSessionEndedEvent?.Invoke(taskId));
});
_hub.On<string, IReadOnlyList<string>>("InteractiveQueueChanged", (taskId, pending) =>
{
Dispatcher.UIThread.Post(() => InteractiveQueueChangedEvent?.Invoke(taskId, pending));
});
_hub.On<string, string>("InteractiveMessageSent", (taskId, text) =>
{
Dispatcher.UIThread.Post(() => InteractiveMessageSentEvent?.Invoke(taskId, text));
});
_hub.On<string>("WorktreeUpdated", taskId => _hub.On<string>("WorktreeUpdated", taskId =>
{ {
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId)); Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
@@ -221,11 +183,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
_hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke())); _hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke()));
_hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line))); _hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line)));
_hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok))); _hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok)));
_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)));
} }
public Task StartAsync() public Task StartAsync()
@@ -287,53 +244,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
catch { return default; } catch { return default; }
} }
public async Task RunNowAsync(string taskId) /// <summary>Invoke a task-targeted hub method under a TaskId log scope, emitting a debug trace line.</summary>
private async Task InvokeForTaskAsync(string taskId, string method, params object?[] args)
{ {
await _hub.InvokeAsync("RunNow", taskId); using (LogContext.PushProperty("TaskId", taskId))
{
_logger.LogDebug("UI invoking {Method} for task {TaskId}", method, taskId);
await _hub.InvokeCoreAsync(method, args);
}
} }
public async Task ContinueTaskAsync(string taskId, string followUpPrompt) public Task RunNowAsync(string taskId)
{ => InvokeForTaskAsync(taskId, "RunNow", taskId);
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
}
public async Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer) public Task ContinueTaskAsync(string taskId, string followUpPrompt)
{ => InvokeForTaskAsync(taskId, "ContinueTask", taskId, followUpPrompt);
try { await _hub.InvokeAsync<bool>("AnswerTaskQuestion", taskId, questionId, answer); }
catch { /* offline or already resolved — the UI clears optimistically */ }
}
public async Task SendInteractiveMessageAsync(string taskId, string text) public Task ResetTaskAsync(string taskId)
{ => InvokeForTaskAsync(taskId, "ResetTask", taskId);
try { await _hub.InvokeAsync("SendInteractiveMessage", taskId, text); }
catch { /* offline or session already ended */ }
}
public async Task RemoveQueuedInteractiveMessageAsync(string taskId, string text)
{
try { await _hub.InvokeAsync("RemoveQueuedInteractiveMessage", taskId, text); }
catch { /* offline or session already ended */ }
}
public async Task StopInteractiveSessionAsync(string taskId)
{
try { await _hub.InvokeAsync("StopInteractiveSession", taskId); }
catch { /* offline */ }
}
public async Task InterruptInteractiveSessionAsync(string taskId)
{
try { await _hub.InvokeAsync("InterruptInteractiveSession", taskId); }
catch { /* offline */ }
}
public Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId)
=> TryInvokeAsync<PendingQuestionDto>("GetPendingQuestion", taskId);
public async Task ResetTaskAsync(string taskId)
{
await _hub.InvokeAsync("ResetTask", taskId);
}
public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
{ {
@@ -341,28 +269,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage); "MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
} }
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
public Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
=> _hub.InvokeAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", taskId);
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
public Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
=> _hub.InvokeAsync<MergeResultDto>("ContinueConflictMerge", taskId);
public Task AbortConflictMergeAsync(string taskId)
=> _hub.InvokeAsync("AbortConflictMerge", taskId);
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId); => TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
public async Task CancelTaskAsync(string taskId) public Task CancelTaskAsync(string taskId)
{ => InvokeForTaskAsync(taskId, "CancelTask", taskId);
await _hub.InvokeAsync("CancelTask", taskId);
}
public async Task WakeQueueAsync() public async Task WakeQueueAsync()
{ {
@@ -440,8 +351,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task<bool> RunDailyPrepNowAsync() public Task<bool> RunDailyPrepNowAsync()
=> _hub.InvokeAsync<bool>("RunDailyPrepNow"); => _hub.InvokeAsync<bool>("RunDailyPrepNow");
public Task RefineTaskAsync(string taskId) => _hub.InvokeAsync("RefineTask", taskId);
public Task ClearMyDayAsync() public Task ClearMyDayAsync()
=> _hub.InvokeAsync("ClearMyDay"); => _hub.InvokeAsync("ClearMyDay");
@@ -460,9 +369,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<string> GetLastPrepLogAsync() public async Task<string> GetLastPrepLogAsync()
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty; => await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
public async Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync()
=> await TryInvokeAsync<List<WorkerLogEntry>>("GetRecentLogs") ?? new List<WorkerLogEntry>();
public async Task UpdateListAsync(UpdateListDto dto) public async Task UpdateListAsync(UpdateListDto dto)
{ {
await _hub.InvokeAsync("UpdateList", dto); await _hub.InvokeAsync("UpdateList", dto);
@@ -486,29 +392,17 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
} }
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) public Task ApproveReviewAsync(string taskId)
{ => InvokeForTaskAsync(taskId, "ApproveReview", taskId);
LastApproveTarget = targetBranch;
return TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
}
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) public Task RejectReviewToQueueAsync(string taskId, string feedback)
=> TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch); => InvokeForTaskAsync(taskId, "RejectReviewToQueue", taskId, feedback);
public async Task RejectReviewToQueueAsync(string taskId, string feedback) public Task RejectReviewToIdleAsync(string taskId)
{ => InvokeForTaskAsync(taskId, "RejectReviewToIdle", taskId);
await _hub.InvokeAsync("RejectReviewToQueue", taskId, feedback);
}
public async Task RejectReviewToIdleAsync(string taskId) public Task CancelReviewAsync(string taskId)
{ => InvokeForTaskAsync(taskId, "CancelReview", taskId);
await _hub.InvokeAsync("RejectReviewToIdle", taskId);
}
public async Task CancelReviewAsync(string taskId)
{
await _hub.InvokeAsync("CancelReview", taskId);
}
public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null) public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
=> TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId); => TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
@@ -564,6 +458,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
=> TryInvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch); => TryInvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
public async Task MergeAllPlanningAsync(string planningTaskId, string targetBranch)
{
LastMergeAllTarget = targetBranch;
await _hub.InvokeAsync("MergeAllPlanning", planningTaskId, targetBranch);
}
public async Task ContinuePlanningMergeAsync(string planningTaskId) public async Task ContinuePlanningMergeAsync(string planningTaskId)
{ {
await _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId); await _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId);
@@ -579,18 +479,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct); await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
} }
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync()
=> TryInvokeAsync<OnlineInboxStateDto>("GetOnlineInboxState");
public async Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input)
=> await _hub.InvokeAsync("SetOnlineInboxConfig", input);
public async Task SetOnlineInboxAuthAsync(string refreshToken)
=> await _hub.InvokeAsync("SetOnlineInboxAuth", refreshToken);
public async Task ClearOnlineInboxAuthAsync()
=> await _hub.InvokeAsync("ClearOnlineInboxAuth");
// IWorkerClient explicit implementations (drop typed return values) // IWorkerClient explicit implementations (drop typed return values)
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct) async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
=> await StartPlanningSessionAsync(taskId, ct); => await StartPlanningSessionAsync(taskId, ct);
@@ -629,11 +517,7 @@ public sealed record AppSettingsDto(
public sealed record WorktreeCleanupDto(int Removed); public sealed record WorktreeCleanupDto(int Removed);
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks); public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage); public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches); public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
@@ -655,23 +539,3 @@ public sealed record WorktreeOverviewDto(
bool PathExistsOnDisk); bool PathExistsOnDisk);
public sealed record ForceRemoveResultDto(bool Removed, string? Reason); public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question);
public sealed record OnlineInboxStateDto(
bool Enabled,
string ApiBaseUrl,
string Authority,
string ClientId,
string Scopes,
string RedirectUri,
bool SignedIn,
int PollIntervalSeconds);
public sealed record OnlineInboxConfigInputDto(
bool Enabled,
string ApiBaseUrl,
int PollIntervalSeconds,
string Authority,
string ClientId,
string Scopes,
string RedirectUri);

View File

@@ -4,8 +4,8 @@ namespace ClaudeDo.Ui.Services;
public sealed class WorkerNotesApi : INotesApi public sealed class WorkerNotesApi : INotesApi
{ {
private readonly IWorkerClient _client; private readonly WorkerClient _client;
public WorkerNotesApi(IWorkerClient client) => _client = client; public WorkerNotesApi(WorkerClient client) => _client = client;
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day); public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day);
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text); public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text);
public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text); public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text);

Some files were not shown because too many files have changed in this diff Show More