diff --git a/docs/explore-notes/external-mcp.md b/docs/explore-notes/external-mcp.md index f74131a0..9795f50d 100644 --- a/docs/explore-notes/external-mcp.md +++ b/docs/explore-notes/external-mcp.md @@ -32,7 +32,14 @@ session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` `ListTasksResult`/`BatchGetTaskResult`, where exactly one of the lean (`TaskRefDto`) and full (`TaskDto`, incl. Description/Result) fields is populated per the flag — keeps a list of verbosely-described tasks from blowing past the response size limit by default. -3. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so +3. **Description style is documented in `McpToolDocs`** (same folder) and shared boilerplate + lives there as `const` strings. Rules: the first sentence says what the tool does *and* when + to reach for it (MCP clients rank tools by that text, so the trigger must not sit behind + return-shape prose); parameters are documented with `[Description]` **on the parameter**, not + in the tool description; result fields appear only where the caller must branch on them + before calling (`isEmpty`, `truncated`, `conflicts`, `available`); no design rationale or + "since this feature was introduced" history. Not test-enforced — review it in PRs. +4. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException` / `ArgumentException` messages survive as `McpException` — otherwise the SDK's catch-all replaces any non-`McpException` with a generic *"An error occurred invoking 'X'."* @@ -42,8 +49,9 @@ session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` ### `ExternalMcpService` — task CRUD, execution, git Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, -`UpdateTaskStatus`, `GetTaskStatusValues`, `ReviewTask`, `RunTaskNow`, `ContinueTask`, -`CancelTask`, `DeleteTask`. +`UpdateTaskStatus`, `ReviewTask`, `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`. +(`GetTaskStatusValues` was removed — a whole tool entry for static reference text. `GetTask`'s +description is now the canonical place for what each status means.) Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`, `PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`. diff --git a/docs/superpowers/plans/2026-08-07-diff-side-by-side.md b/docs/superpowers/plans/2026-08-07-diff-side-by-side.md new file mode 100644 index 00000000..65c5f955 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-diff-side-by-side.md @@ -0,0 +1,1658 @@ +# Diff Side-by-Side 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:** Give the diff viewer a persistent Unified/Side-by-Side toggle with TextMate syntax highlighting, intra-line word highlighting, and an optional line-wrap toggle. + +**Architecture:** A new pure helper (`DiffAlignment`) turns the parsed `DiffLineViewModel` stream into aligned left/right rows plus word-diff spans. A new AvaloniaEdit-based control (`DiffTextView`) renders those rows — one editor in unified mode, two synced editors in split mode — with TextMate highlighting, a custom line-number margin and two background renderers. `DiffLinesView` is deleted once both call sites migrate. The toggle state lives in `ui.config.json`. + +**Tech Stack:** .NET 8, Avalonia 12, AvaloniaEdit + AvaloniaEdit.TextMate + TextMateSharp.Grammars (all already referenced in `ClaudeDo.Ui.csproj`), CommunityToolkit.Mvvm, xUnit. + +**Spec:** `docs/superpowers/specs/2026-08-07-diff-side-by-side-design.md` + +**Deviations from the spec, decided while planning:** +- The control's layout property is `IsSplit` (bool), not `Mode` (enum) — drops an enum and a converter for zero benefit. +- A third word-diff guard `MaxWordDiffTokens = 400` is added. The LCS table is O(n·m); 2000 chars can tokenize to ~2000 tokens, which would allocate a 16 MB table. +- `AppSettings`'s config path is a hardcoded private static. Task 9 makes the ViewModel call `Save()` whenever a toggle flips, and `DiffViewerViewModelTests` constructs that ViewModel eight times — so without a change, a test that touches a toggle would overwrite the developer's real `~/.todo-app/ui.config.json`. Task 3 therefore makes the path an instance property so both the round-trip test and the ViewModel tests stay in a temp directory. + +**Build/test commands** (`.slnx` needs .NET 9 — build individual csproj; a running Worker locks `Debug`, so always `-c Release`): + +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release +``` + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| Create `src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs` | Pure: parsed lines → aligned rows, gap rows, word-diff spans. No Avalonia types. | +| Create `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml` + `.axaml.cs` | The renderer: 1–2 `TextEditor`s, TextMate, margin, renderers, scroll sync. Single-consumer helper types (`RowInfo`, `Seg`, margin, renderers) live in the `.axaml.cs` per repo convention. | +| Modify `src/ClaudeDo.Ui/AppSettings.cs` | Two new persisted view preferences. | +| Modify `src/ClaudeDo.Ui/Design/Tokens.axaml` | Filler/gap/word-diff brushes. | +| Modify `src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs` | Toggle state + persistence; `DiffLines` → `PlanningFiles`. | +| Modify `src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml` | Toolbar toggles; both `DiffLinesView` call sites → `DiffTextView`. | +| Modify `src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs` | Remove `Flatten` (last consumer goes away). | +| Delete `src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml` + `.axaml.cs` | Replaced. | +| Modify `src/ClaudeDo.Localization/locales/{en,de}.json` | `modals.diff.splitView`, `modals.diff.wrapLines`. | +| Create `tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs` | All alignment + word-diff behaviour. | +| Create `tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs` | JSON contract for the two new properties. | + +--- + +## Task 1: `DiffAlignment` types and context/change-block pairing + +**Files:** +- Create: `src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs` +- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs` + +Background: `DiffLineViewModel` (`src/ClaudeDo.Ui/ViewModels/Modals/DiffModels.cs:12`) has `Kind` (`Add`/`Del`/`Ctx`/`File`), `OldNo`, `NewNo`, `Text`. `UnifiedDiffParser` emits `Del` runs before `Add` runs inside a change block, and drops `@@` headers — so a skipped region shows up only as a jump in the line numbers. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs`: + +```csharp +using ClaudeDo.Ui.ViewModels.Modals; +using Xunit; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class DiffAlignmentTests +{ + private static DiffLineViewModel Ctx(int oldNo, int newNo, string text) => + new() { Kind = DiffLineKind.Ctx, OldNo = oldNo, NewNo = newNo, Text = text }; + + private static DiffLineViewModel Del(int oldNo, string text) => + new() { Kind = DiffLineKind.Del, OldNo = oldNo, Text = text }; + + private static DiffLineViewModel Add(int newNo, string text) => + new() { Kind = DiffLineKind.Add, NewNo = newNo, Text = text }; + + [Fact] + public void NullOrEmpty_YieldsEmptyDiff() + { + Assert.Empty(DiffAlignment.Build(null).SplitRows); + Assert.Empty(DiffAlignment.Build(Array.Empty()).SplitRows); + } + + [Fact] + public void ContextOnly_MirrorsBothSides_WithNoFillers() + { + var result = DiffAlignment.Build(new[] { Ctx(1, 1, "a"), Ctx(2, 2, "b") }); + + Assert.Equal(2, result.SplitRows.Count); + Assert.All(result.SplitRows, r => + { + Assert.Equal(AlignedSide.Ctx, r.LeftKind); + Assert.Equal(AlignedSide.Ctx, r.RightKind); + Assert.Equal(r.LeftText, r.RightText); + }); + Assert.Equal("a\nb", result.LeftText); + Assert.Equal("a\nb", result.RightText); + } + + [Fact] + public void EqualSizedChangeBlock_PairsOneToOne() + { + var result = DiffAlignment.Build(new[] + { + Del(1, "old one"), Del(2, "old two"), + Add(1, "new one"), Add(2, "new two"), + }); + + Assert.Equal(2, result.SplitRows.Count); + Assert.Equal(AlignedSide.Del, result.SplitRows[0].LeftKind); + Assert.Equal(AlignedSide.Add, result.SplitRows[0].RightKind); + Assert.Equal("old one", result.SplitRows[0].LeftText); + Assert.Equal("new one", result.SplitRows[0].RightText); + Assert.Equal(1, result.SplitRows[0].OldNo!.Value); + Assert.Equal(1, result.SplitRows[0].NewNo!.Value); + } + + [Fact] + public void MoreAddsThanDels_PadsTheLeftSideWithFillers() + { + var result = DiffAlignment.Build(new[] + { + Del(1, "d1"), + Add(1, "a1"), Add(2, "a2"), Add(3, "a3"), + }); + + Assert.Equal(3, result.SplitRows.Count); + Assert.Equal(AlignedSide.Del, result.SplitRows[0].LeftKind); + Assert.Equal(AlignedSide.Filler, result.SplitRows[1].LeftKind); + Assert.Equal(AlignedSide.Filler, result.SplitRows[2].LeftKind); + Assert.Equal("", result.SplitRows[1].LeftText); + Assert.Null(result.SplitRows[1].OldNo); + Assert.All(result.SplitRows, r => Assert.Equal(AlignedSide.Add, r.RightKind)); + } + + [Fact] + public void MoreDelsThanAdds_PadsTheRightSideWithFillers() + { + var result = DiffAlignment.Build(new[] + { + Del(1, "d1"), Del(2, "d2"), Del(3, "d3"), + Add(1, "a1"), + }); + + Assert.Equal(3, result.SplitRows.Count); + Assert.Equal(AlignedSide.Add, result.SplitRows[0].RightKind); + Assert.Equal(AlignedSide.Filler, result.SplitRows[1].RightKind); + Assert.Equal(AlignedSide.Filler, result.SplitRows[2].RightKind); + Assert.All(result.SplitRows, r => Assert.Equal(AlignedSide.Del, r.LeftKind)); + } + + [Fact] + public void NonContiguousLineNumbers_InsertOneGapRow() + { + var result = DiffAlignment.Build(new[] { Ctx(1, 1, "a"), Ctx(40, 40, "b") }); + + Assert.Equal(3, result.SplitRows.Count); + Assert.Equal(AlignedSide.Gap, result.SplitRows[1].LeftKind); + Assert.Equal(AlignedSide.Gap, result.SplitRows[1].RightKind); + Assert.Null(result.SplitRows[1].OldNo); + Assert.Null(result.SplitRows[1].NewNo); + } + + [Fact] + public void FirstLineNeverProducesALeadingGap() + { + var result = DiffAlignment.Build(new[] { Ctx(120, 118, "a") }); + + Assert.Single(result.SplitRows); + Assert.Equal(AlignedSide.Ctx, result.SplitRows[0].LeftKind); + } + + [Fact] + public void UnifiedRows_KeepGitOrder_DeletionsThenAdditions() + { + var result = DiffAlignment.Build(new[] + { + Ctx(1, 1, "keep"), + Del(2, "d1"), Del(3, "d2"), + Add(2, "a1"), + }); + + Assert.Equal(4, result.UnifiedRows.Count); + Assert.Equal(AlignedSide.Ctx, result.UnifiedRows[0].Kind); + Assert.Equal(AlignedSide.Del, result.UnifiedRows[1].Kind); + Assert.Equal(AlignedSide.Del, result.UnifiedRows[2].Kind); + Assert.Equal(AlignedSide.Add, result.UnifiedRows[3].Kind); + Assert.Equal("keep\nd1\nd2\na1", result.UnifiedText); + } + + [Fact] + public void RowIndex_MapsToDocumentLine_OnBothSides() + { + var result = DiffAlignment.Build(new[] + { + Ctx(1, 1, "a"), + Del(2, "d"), Add(2, "x"), Add(3, "y"), + Ctx(3, 4, "b"), + }); + + var leftLines = result.LeftText.Split('\n'); + var rightLines = result.RightText.Split('\n'); + Assert.Equal(result.SplitRows.Count, leftLines.Length); + Assert.Equal(result.SplitRows.Count, rightLines.Length); + for (var i = 0; i < result.SplitRows.Count; i++) + { + Assert.Equal(result.SplitRows[i].LeftText, leftLines[i]); + Assert.Equal(result.SplitRows[i].RightText, rightLines[i]); + } + + var unifiedLines = result.UnifiedText.Split('\n'); + Assert.Equal(result.UnifiedRows.Count, unifiedLines.Length); + } + + [Fact] + public void FileHeaderRows_AreIgnored() + { + var result = DiffAlignment.Build(new[] + { + new DiffLineViewModel { Kind = DiffLineKind.File, Text = "src/Foo.cs" }, + Ctx(1, 1, "a"), + }); + + Assert.Single(result.SplitRows); + Assert.Equal("a", result.SplitRows[0].LeftText); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DiffAlignmentTests +``` + +Expected: compile error — `DiffAlignment`, `AlignedSide` do not exist. + +- [ ] **Step 3: Write the implementation** + +Create `src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs`: + +```csharp +namespace ClaudeDo.Ui.ViewModels.Modals; + +/// Which role a row plays on one side of the split view. +public enum AlignedSide { Ctx, Del, Add, Filler, Gap } + +/// A character range inside a row's text, used for intra-line (word) highlighting. +public readonly record struct TextSpan(int Start, int Length); + +/// One rendered row of the side-by-side view. Left is the old state, right the new. +public sealed record SplitRow( + AlignedSide LeftKind, int? OldNo, string LeftText, IReadOnlyList LeftSpans, + AlignedSide RightKind, int? NewNo, string RightText, IReadOnlyList RightSpans); + +/// One rendered row of the single-pane view. +public sealed record UnifiedRow( + AlignedSide Kind, int? OldNo, int? NewNo, string Text, IReadOnlyList Spans); + +/// Render-ready diff. Row index i is document line i + 1 in the matching text — +/// that mapping is what the line-number margin and both background renderers rely on. +public sealed record AlignedDiff( + IReadOnlyList SplitRows, string LeftText, string RightText, + IReadOnlyList UnifiedRows, string UnifiedText) +{ + public static readonly AlignedDiff Empty = new( + Array.Empty(), "", "", Array.Empty(), ""); +} + +/// Turns a parsed unified-diff line stream into aligned rows. Pure — no Avalonia types, +/// so all of the interesting behaviour is unit-testable. +public static class DiffAlignment +{ + /// Marker text for a skipped region between two hunks. + public const string GapText = "⋯"; + + internal static readonly IReadOnlyList NoSpans = Array.Empty(); + + public static AlignedDiff Build(IReadOnlyList? lines) + { + if (lines is null || lines.Count == 0) return AlignedDiff.Empty; + + var split = new List(); + var unified = new List(); + int? prevOld = null, prevNew = null; + + var i = 0; + while (i < lines.Count) + { + var line = lines[i]; + + // File-header rows only appear in the legacy flattened stream; alignment is per file. + if (line.Kind == DiffLineKind.File) { i++; continue; } + + if (IsGap(prevOld, prevNew, line)) + { + split.Add(new SplitRow(AlignedSide.Gap, null, GapText, NoSpans, + AlignedSide.Gap, null, GapText, NoSpans)); + unified.Add(new UnifiedRow(AlignedSide.Gap, null, null, GapText, NoSpans)); + } + + if (line.Kind == DiffLineKind.Ctx) + { + split.Add(new SplitRow(AlignedSide.Ctx, line.OldNo, line.Text, NoSpans, + AlignedSide.Ctx, line.NewNo, line.Text, NoSpans)); + unified.Add(new UnifiedRow(AlignedSide.Ctx, line.OldNo, line.NewNo, line.Text, NoSpans)); + prevOld = line.OldNo; + prevNew = line.NewNo; + i++; + continue; + } + + // A change block: the parser always emits the deletions before the additions. + var dels = new List(); + while (i < lines.Count && lines[i].Kind == DiffLineKind.Del) dels.Add(lines[i++]); + var adds = new List(); + while (i < lines.Count && lines[i].Kind == DiffLineKind.Add) adds.Add(lines[i++]); + + EmitChangeBlock(dels, adds, split, unified); + + if (dels.Count > 0) prevOld = dels[^1].OldNo; + if (adds.Count > 0) prevNew = adds[^1].NewNo; + } + + return new AlignedDiff( + split, + string.Join('\n', split.Select(r => r.LeftText)), + string.Join('\n', split.Select(r => r.RightText)), + unified, + string.Join('\n', unified.Select(r => r.Text))); + } + + /// The parser drops "@@" headers, so a skipped region is visible only as a jump in the + /// line numbers. Nothing precedes the first row, so it can never open with a gap. + private static bool IsGap(int? prevOld, int? prevNew, DiffLineViewModel next) + { + if (prevOld is { } po && next.OldNo is { } no && no > po + 1) return true; + if (prevNew is { } pn && next.NewNo is { } nn && nn > pn + 1) return true; + return false; + } + + private static void EmitChangeBlock( + List dels, List adds, + List split, List unified) + { + var paired = Math.Min(dels.Count, adds.Count); + + // Word diff is only meaningful for rows that stand 1:1 opposite each other. + var spans = new (IReadOnlyList Left, IReadOnlyList Right)[paired]; + for (var k = 0; k < paired; k++) + spans[k] = WordDiff(dels[k].Text, adds[k].Text); + + for (var k = 0; k < paired; k++) + split.Add(new SplitRow(AlignedSide.Del, dels[k].OldNo, dels[k].Text, spans[k].Left, + AlignedSide.Add, adds[k].NewNo, adds[k].Text, spans[k].Right)); + for (var k = paired; k < dels.Count; k++) + split.Add(new SplitRow(AlignedSide.Del, dels[k].OldNo, dels[k].Text, NoSpans, + AlignedSide.Filler, null, "", NoSpans)); + for (var k = paired; k < adds.Count; k++) + split.Add(new SplitRow(AlignedSide.Filler, null, "", NoSpans, + AlignedSide.Add, adds[k].NewNo, adds[k].Text, NoSpans)); + + for (var k = 0; k < dels.Count; k++) + unified.Add(new UnifiedRow(AlignedSide.Del, dels[k].OldNo, null, dels[k].Text, + k < paired ? spans[k].Left : NoSpans)); + for (var k = 0; k < adds.Count; k++) + unified.Add(new UnifiedRow(AlignedSide.Add, null, adds[k].NewNo, adds[k].Text, + k < paired ? spans[k].Right : NoSpans)); + } + + /// Placeholder until Task 2 fills in the token LCS. + internal static (IReadOnlyList Left, IReadOnlyList Right) WordDiff( + string left, string right) => (NoSpans, NoSpans); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DiffAlignmentTests +``` + +Expected: PASS, 10 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs +git commit -m "feat(diff): align parsed diff lines into side-by-side rows" -- src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs +``` + +> **Repo rule:** the main checkout is shared with concurrent sessions. Always commit with an explicit `-- ` list; never `git add -A` and never a bare `git commit`. + +--- + +## Task 2: Word diff inside paired lines + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs` (replace the `WordDiff` placeholder) +- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs` (append) + +- [ ] **Step 1: Write the failing tests** + +Append these methods inside the existing `DiffAlignmentTests` class: + +```csharp + [Fact] + public void WordDiff_HighlightsOnlyTheChangedToken() + { + var result = DiffAlignment.Build(new[] + { + Del(1, " var x = 1;"), + Add(1, " var x = 2;"), + }); + + var row = Assert.Single(result.SplitRows); + var left = Assert.Single(row.LeftSpans); + var right = Assert.Single(row.RightSpans); + Assert.Equal(new TextSpan(12, 1), left); + Assert.Equal(new TextSpan(12, 1), right); + Assert.Equal("1", row.LeftText.Substring(left.Start, left.Length)); + Assert.Equal("2", row.RightText.Substring(right.Start, right.Length)); + } + + [Fact] + public void WordDiff_MergesAdjacentChangedTokensIntoOneSpan() + { + var result = DiffAlignment.Build(new[] + { + Del(1, "call(a, b);"), + Add(1, "call(zz, b);"), + }); + + var row = Assert.Single(result.SplitRows); + var right = Assert.Single(row.RightSpans); + Assert.Equal("zz", row.RightText.Substring(right.Start, right.Length)); + } + + [Fact] + public void WordDiff_SkippedWhenTheLinesAreUnrelated() + { + var result = DiffAlignment.Build(new[] + { + Del(1, "public void Alpha()"), + Add(1, "return 42;"), + }); + + var row = Assert.Single(result.SplitRows); + Assert.Empty(row.LeftSpans); + Assert.Empty(row.RightSpans); + } + + [Fact] + public void WordDiff_SkippedOnVeryLongLines() + { + var left = new string('a', DiffAlignment.MaxWordDiffChars + 1); + var result = DiffAlignment.Build(new[] { Del(1, left), Add(1, left + "b") }); + + var row = Assert.Single(result.SplitRows); + Assert.Empty(row.LeftSpans); + Assert.Empty(row.RightSpans); + } + + [Fact] + public void WordDiff_SkippedWhenTokenCountExceedsTheCap() + { + var many = string.Join(" ", Enumerable.Range(0, DiffAlignment.MaxWordDiffTokens + 10).Select(n => $"t{n}")); + var result = DiffAlignment.Build(new[] { Del(1, many), Add(1, many + " x") }); + + var row = Assert.Single(result.SplitRows); + Assert.Empty(row.LeftSpans); + Assert.Empty(row.RightSpans); + } + + [Fact] + public void WordDiff_IdenticalTextYieldsNoSpans() + { + var result = DiffAlignment.Build(new[] { Del(1, "same"), Add(1, "same") }); + + var row = Assert.Single(result.SplitRows); + Assert.Empty(row.LeftSpans); + Assert.Empty(row.RightSpans); + } + + [Fact] + public void WordDiff_UnpairedOverhangRowsHaveNoSpans() + { + var result = DiffAlignment.Build(new[] + { + Del(1, "var x = 1;"), + Add(1, "var x = 2;"), Add(2, "var y = 3;"), + }); + + Assert.NotEmpty(result.SplitRows[0].RightSpans); + Assert.Empty(result.SplitRows[1].RightSpans); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DiffAlignmentTests +``` + +Expected: the 7 new tests fail (`MaxWordDiffChars` / `MaxWordDiffTokens` do not compile; the span assertions fail because `WordDiff` returns nothing). + +- [ ] **Step 3: Write the implementation** + +In `src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs`, replace the `WordDiff` placeholder method with the following, and add the constants directly under `GapText`: + +```csharp + /// Word diff is O(n·m) in tokens and is noise on lines that were rewritten wholesale, + /// so it is skipped on very long lines, on token-heavy lines, and on dissimilar pairs. + public const int MaxWordDiffChars = 2000; + public const int MaxWordDiffTokens = 400; + public const double MinWordDiffSimilarity = 0.5; +``` + +```csharp + /// Changed character ranges on each side of a 1:1 line pair, or no spans when the pair + /// is too long, too token-heavy or too dissimilar for per-word highlighting to help. + internal static (IReadOnlyList Left, IReadOnlyList Right) WordDiff( + string left, string right) + { + if (left.Length == 0 || right.Length == 0 || string.Equals(left, right, StringComparison.Ordinal)) + return (NoSpans, NoSpans); + if (left.Length > MaxWordDiffChars || right.Length > MaxWordDiffChars) + return (NoSpans, NoSpans); + + var a = Tokenize(left); + var b = Tokenize(right); + if (a.Count > MaxWordDiffTokens || b.Count > MaxWordDiffTokens) + return (NoSpans, NoSpans); + + var (keepA, keepB, common) = LongestCommonSubsequence(a, b); + var similarity = common / (double)Math.Max(a.Count, b.Count); + if (similarity < MinWordDiffSimilarity) return (NoSpans, NoSpans); + + return (SpansForUnmatched(a, keepA), SpansForUnmatched(b, keepB)); + } + + /// Splits a line into runs of word characters, runs of whitespace, and single + /// punctuation characters — the granularity that makes an identifier rename read as + /// one changed token rather than a string of changed characters. + private static List Tokenize(string s) + { + var tokens = new List(); + var i = 0; + while (i < s.Length) + { + var start = i; + if (IsWordChar(s[i])) + while (i < s.Length && IsWordChar(s[i])) i++; + else if (char.IsWhiteSpace(s[i])) + while (i < s.Length && char.IsWhiteSpace(s[i])) i++; + else + i++; + tokens.Add(new Token(start, s[start..i])); + } + return tokens; + } + + private static bool IsWordChar(char c) => char.IsLetterOrDigit(c) || c == '_'; + + private static (bool[] KeepA, bool[] KeepB, int Common) LongestCommonSubsequence( + List a, List b) + { + var n = a.Count; + var m = b.Count; + var dp = new int[n + 1, m + 1]; + for (var i = n - 1; i >= 0; i--) + for (var j = m - 1; j >= 0; j--) + dp[i, j] = string.Equals(a[i].Text, b[j].Text, StringComparison.Ordinal) + ? dp[i + 1, j + 1] + 1 + : Math.Max(dp[i + 1, j], dp[i, j + 1]); + + var keepA = new bool[n]; + var keepB = new bool[m]; + int x = 0, y = 0; + while (x < n && y < m) + { + if (string.Equals(a[x].Text, b[y].Text, StringComparison.Ordinal)) + { + keepA[x] = keepB[y] = true; + x++; y++; + } + else if (dp[x + 1, y] >= dp[x, y + 1]) x++; + else y++; + } + return (keepA, keepB, dp[0, 0]); + } + + /// Merges runs of consecutive unmatched tokens so "zz" is one span, not two. + private static IReadOnlyList SpansForUnmatched(List tokens, bool[] keep) + { + var spans = new List(); + var i = 0; + while (i < tokens.Count) + { + if (keep[i]) { i++; continue; } + var start = tokens[i].Start; + var end = start + tokens[i].Text.Length; + i++; + while (i < tokens.Count && !keep[i]) + { + end = tokens[i].Start + tokens[i].Text.Length; + i++; + } + spans.Add(new TextSpan(start, end - start)); + } + return spans.Count == 0 ? NoSpans : spans; + } + + /// A tokenized slice of a line, carrying its offset so spans map back to characters. + private readonly record struct Token(int Start, string Text); +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DiffAlignmentTests +``` + +Expected: PASS, 17 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs +git commit -m "feat(diff): highlight changed words inside paired diff lines" -- src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs +``` + +--- + +## Task 3: Persist the view preferences + +**Files:** +- Modify: `src/ClaudeDo.Ui/AppSettings.cs` +- Test: `tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs` (create) + +Background: `AppSettings` (`src/ClaudeDo.Ui/AppSettings.cs`) already holds UI-only preferences (`Language`, `AccentPreset`) in `~/.todo-app/ui.config.json` and is registered as a singleton in `src/ClaudeDo.App/Program.cs:86`. `Load()` deserializes with `PropertyNameCaseInsensitive = true`. + +The config path is currently a hardcoded `private static readonly string ConfigPath`. This task makes it an instance property so tests can redirect it — necessary because Task 9 saves on every toggle flip and the existing `DiffViewerViewModelTests` would otherwise write to the developer's real config file. + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs`: + +```csharp +using System.Text.Json; +using ClaudeDo.Ui; +using Xunit; + +namespace ClaudeDo.Ui.Tests; + +public class AppSettingsTests +{ + private static string TempConfigPath() => + Path.Combine(Path.GetTempPath(), $"claudedo-uicfg-{Guid.NewGuid():N}.json"); + + [Fact] + public void DiffPreferences_DefaultToUnifiedAndNoWrap() + { + var settings = new AppSettings(); + + Assert.Equal("unified", settings.DiffViewMode); + Assert.False(settings.DiffWrapLines); + } + + [Fact] + public void DiffPreferences_SurviveSaveAndLoad() + { + var path = TempConfigPath(); + try + { + new AppSettings { ConfigPath = path, DiffViewMode = "split", DiffWrapLines = true }.Save(); + + var restored = AppSettings.Load(path); + + Assert.Equal("split", restored.DiffViewMode); + Assert.True(restored.DiffWrapLines); + Assert.Equal(path, restored.ConfigPath); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void ConfigPath_IsNotWrittenIntoTheConfigFile() + { + var path = TempConfigPath(); + try + { + new AppSettings { ConfigPath = path }.Save(); + + Assert.DoesNotContain("ConfigPath", File.ReadAllText(path), StringComparison.OrdinalIgnoreCase); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void DiffPreferences_ReadFromCamelCasedConfig() + { + const string json = """{"diffViewMode":"split","diffWrapLines":true}"""; + + var restored = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + + Assert.Equal("split", restored.DiffViewMode); + Assert.True(restored.DiffWrapLines); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter AppSettingsTests +``` + +Expected: compile error — `AppSettings` has no `DiffViewMode` and no settable `ConfigPath`. + +- [ ] **Step 3: Write the implementation** + +In `src/ClaudeDo.Ui/AppSettings.cs`, add both preferences after `AccentPreset`: + +```csharp + /// Diff viewer layout: "unified" or "split". A view preference, so it lives here in + /// ui.config.json rather than in the worker-owned AppSettingsEntity. + public string DiffViewMode { get; set; } = "unified"; + public bool DiffWrapLines { get; set; } +``` + +Replace the `ConfigPath` field with a redirectable instance property: + +```csharp + private static readonly string DefaultConfigPath = Paths.Expand("~/.todo-app/ui.config.json"); + + /// Where this instance persists. Instance-level (not static) so tests can redirect it — + /// the diff-viewer toggles call Save() on every flip. + [JsonIgnore] + public string ConfigPath { get; set; } = DefaultConfigPath; +``` + +Change `Load` to accept an optional path and stamp it onto the result: + +```csharp + public static AppSettings Load(string? configPath = null) + { + var path = configPath ?? DefaultConfigPath; + try + { + if (File.Exists(path)) + { + var json = File.ReadAllText(path); + var loaded = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (loaded is not null) + { + loaded.ConfigPath = path; + return loaded; + } + } + } + catch + { + // Fall through to defaults + } + return new AppSettings { ConfigPath = path }; + } +``` + +and change `Save()` to use the instance path — replace the two `ConfigPath` reads in the existing body with the property (`Path.GetDirectoryName(ConfigPath)` and `File.WriteAllText(ConfigPath, json)` now resolve to the instance property, so the body itself is unchanged). + +Add `using System.Text.Json.Serialization;` for `[JsonIgnore]`. + +`Program.cs:81` calls `AppSettings.Load()` with no argument and keeps working. + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter AppSettingsTests +``` + +Expected: PASS, 4 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/AppSettings.cs tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs +git commit -m "feat(diff): persist diff view mode and wrap preference in ui config" -- src/ClaudeDo.Ui/AppSettings.cs tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs +``` + +--- + +## Task 4: Diff brushes + +**Files:** +- Modify: `src/ClaudeDo.Ui/Design/Tokens.axaml` + +Background: the merge-editor tints sit at `Tokens.axaml:106-110`. Status tints (`RunningTintBrush` `#1F7C9166`, `ErrorTintBrush` `#1FC87060`) are already low-alpha, so syntax-highlighted foreground text stays legible on top of them; the diff view reuses those for whole-line add/delete tints and needs three new brushes of its own. + +- [ ] **Step 1: Add the brushes** + +In `src/ClaudeDo.Ui/Design/Tokens.axaml`, directly after the `MergeResolvedEdgeBrush` line that closes the merge block, add: + +```xml + + + + + +``` + +- [ ] **Step 2: Verify the build** + +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +``` + +Expected: build succeeded. (An unparseable `Tokens.axaml` fails the Avalonia XAML compiler, so a clean build is the check here.) + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Ui/Design/Tokens.axaml +git commit -m "feat(diff): add filler, gap and word-diff brushes" -- src/ClaudeDo.Ui/Design/Tokens.axaml +``` + +--- + +## Task 5: `DiffTextView` — editors, documents, TextMate, wrap + +**Files:** +- Create: `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml` +- Create: `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs` + +This task builds the control and gets text into it. The margin (Task 6), the background renderers (Task 7) and scroll sync (Task 8) are layered on afterwards. Nothing consumes the control yet, so the app must still build and run unchanged. + +Reference implementation for TextMate setup: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs:77-98` (install) and `:393-404` (grammar by extension). `IsReadOnly="True"` blocks user input only — assigning `editor.Text` still works, as that view does. + +- [ ] **Step 1: Create the view** + +Create `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml`: + +```xml + + + + + + + + + + + + +``` + +- [ ] **Step 2: Create the code-behind** + +Create `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using Avalonia; +using Avalonia.Controls; +using AvaloniaEdit; +using AvaloniaEdit.TextMate; +using ClaudeDo.Ui.ViewModels.Modals; +using TextMateSharp.Grammars; + +namespace ClaudeDo.Ui.Views.Controls; + +/// +/// Read-only diff renderer. false shows one editor with the unified +/// stream; true shows the old state on the left and the new state on the right, aligned by +/// . Syntax highlighting comes from TextMate, keyed off the file's +/// extension — the same mechanism the 3-pane conflict resolver uses. +/// +public partial class DiffTextView : UserControl +{ + public static readonly StyledProperty FileProperty = + AvaloniaProperty.Register(nameof(File)); + + public static readonly StyledProperty IsSplitProperty = + AvaloniaProperty.Register(nameof(IsSplit)); + + public static readonly StyledProperty WrapLinesProperty = + AvaloniaProperty.Register(nameof(WrapLines)); + + public DiffFileViewModel? File + { + get => GetValue(FileProperty); + set => SetValue(FileProperty, value); + } + + public bool IsSplit + { + get => GetValue(IsSplitProperty); + set => SetValue(IsSplitProperty, value); + } + + public bool WrapLines + { + get => GetValue(WrapLinesProperty); + set => SetValue(WrapLinesProperty, value); + } + + // Grammars and themes are process-wide; loading the registry per control would be wasteful. + private static readonly RegistryOptions Registry = new(ThemeName.DarkPlus); + + private TextMate.Installation? _leftTm, _rightTm; + + // Row lookup per editor, indexed by document line number (1-based). Populated on rebuild + // and consumed by the margin and background renderers added in later tasks. + private RowInfo?[] _leftRows = Array.Empty(); + private RowInfo?[] _rightRows = Array.Empty(); + + private AlignedDiff _aligned = AlignedDiff.Empty; + + public DiffTextView() + { + InitializeComponent(); + _leftTm = LeftEditor.InstallTextMate(Registry); + _rightTm = RightEditor.InstallTextMate(Registry); + Rebuild(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == FileProperty || change.Property == IsSplitProperty) + Rebuild(); + else if (change.Property == WrapLinesProperty) + ApplyWrap(); + } + + private void Rebuild() + { + _aligned = DiffAlignment.Build(File?.Lines); + + var split = IsSplit; + RightEditor.IsVisible = split; + PaneDivider.IsVisible = split; + Grid.SetColumnSpan(LeftEditor, split ? 1 : 3); + + if (split) + { + LeftEditor.Text = _aligned.LeftText; + RightEditor.Text = _aligned.RightText; + _leftRows = BuildRows(_aligned.SplitRows.Count, + i => new RowInfo(_aligned.SplitRows[i].LeftKind, _aligned.SplitRows[i].OldNo, null, + _aligned.SplitRows[i].LeftSpans)); + _rightRows = BuildRows(_aligned.SplitRows.Count, + i => new RowInfo(_aligned.SplitRows[i].RightKind, null, _aligned.SplitRows[i].NewNo, + _aligned.SplitRows[i].RightSpans)); + } + else + { + LeftEditor.Text = _aligned.UnifiedText; + RightEditor.Text = ""; + _leftRows = BuildRows(_aligned.UnifiedRows.Count, + i => new RowInfo(_aligned.UnifiedRows[i].Kind, _aligned.UnifiedRows[i].OldNo, + _aligned.UnifiedRows[i].NewNo, _aligned.UnifiedRows[i].Spans)); + _rightRows = Array.Empty(); + } + + ApplyWrap(); + ApplyGrammar(File?.Path); + } + + /// Row index i is document line i + 1, so slot 0 stays null and lookups can pass the + /// line number straight through. + private static RowInfo?[] BuildRows(int count, Func project) + { + var rows = new RowInfo?[count + 1]; + for (var i = 0; i < count; i++) rows[i + 1] = project(i); + return rows; + } + + private RowInfo? LeftRow(int line) => + line > 0 && line < _leftRows.Length ? _leftRows[line] : null; + + private RowInfo? RightRow(int line) => + line > 0 && line < _rightRows.Length ? _rightRows[line] : null; + + private void ApplyWrap() + { + LeftEditor.WordWrap = WrapLines; + RightEditor.WordWrap = WrapLines; + } + + /// Only hunks are in the document, not whole files, so TextMate's line-by-line state can + /// be wrong at a fragment boundary (a line inside a block comment may highlight as code). + /// Accepted — every fragment-based diff viewer has this. + private void ApplyGrammar(string? path) + { + if (string.IsNullOrEmpty(path)) return; + var ext = Path.GetExtension(path); + if (string.IsNullOrEmpty(ext)) return; + var language = Registry.GetLanguageByExtension(ext); + if (language is null) return; + var scope = Registry.GetScopeByLanguageId(language.Id); + _leftTm?.SetGrammar(scope); + _rightTm?.SetGrammar(scope); + } + + /// What one document line represents, for the margin and the background renderers. + internal sealed record RowInfo( + AlignedSide Kind, int? OldNo, int? NewNo, IReadOnlyList Spans); +} +``` + +- [ ] **Step 3: Verify the build** + +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +``` + +Expected: build succeeded, no warnings about `DiffTextView`. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs +git commit -m "feat(diff): add AvaloniaEdit-based diff control with TextMate highlighting" -- src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs +``` + +--- + +## Task 6: Line-number margin + +**Files:** +- Modify: `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs` + +The margin draws old numbers in the left editor and new numbers in the right editor when split; in unified mode the left editor's margin draws both columns. `Filler` and `Gap` rows have no number and draw nothing. + +- [ ] **Step 1: Add the margin type** + +In `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs`, add these usings: + +```csharp +using Avalonia.Media; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; +``` + +and add the margin as a nested type next to `RowInfo`: + +```csharp + /// Draws one or two gutter columns of diff line numbers from the row lookup. + private sealed class DiffLineNumberMargin : AbstractMargin + { + private const double ColumnWidth = 44; + private const double Gap = 6; + + private readonly Func _rows; + private readonly bool _showOld; + private readonly bool _showNew; + private readonly IBrush _foreground; + private readonly Typeface _typeface; + private readonly double _fontSize; + + public DiffLineNumberMargin(Func rows, bool showOld, bool showNew, + IBrush foreground, Typeface typeface, double fontSize) + { + _rows = rows; + _showOld = showOld; + _showNew = showNew; + _foreground = foreground; + _typeface = typeface; + _fontSize = fontSize; + } + + private double Columns => (_showOld ? 1 : 0) + (_showNew ? 1 : 0); + + protected override Size MeasureOverride(Size availableSize) => + new(Columns * ColumnWidth + Gap, 0); + + protected override void OnTextViewChanged(TextView? oldTextView, TextView? newTextView) + { + if (oldTextView is not null) oldTextView.VisualLinesChanged -= OnVisualLinesChanged; + base.OnTextViewChanged(oldTextView, newTextView); + if (newTextView is not null) newTextView.VisualLinesChanged += OnVisualLinesChanged; + InvalidateVisual(); + } + + private void OnVisualLinesChanged(object? sender, EventArgs e) => InvalidateVisual(); + + public override void Render(DrawingContext context) + { + var textView = TextView; + if (textView is null || !textView.VisualLinesValid) return; + + foreach (var visualLine in textView.VisualLines) + { + var lineNumber = visualLine.FirstDocumentLine.LineNumber; + if (_rows(lineNumber) is not { } row) continue; + + var y = visualLine.VisualTop - textView.ScrollOffset.Y; + var column = 0; + if (_showOld) DrawNumber(context, row.OldNo, column++, y); + if (_showNew) DrawNumber(context, row.NewNo, column, y); + } + } + + private void DrawNumber(DrawingContext context, int? value, int column, double y) + { + if (value is null) return; + var text = new FormattedText(value.Value.ToString(), CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, _typeface, _fontSize, _foreground); + // Right-align inside the column so the digits line up across rows. + var x = (column + 1) * ColumnWidth - text.Width - Gap; + context.DrawText(text, new Point(x, y)); + } + } +``` + +Add `using System.Globalization;` for `CultureInfo`. + +- [ ] **Step 2: Attach the margins** + +In `DiffTextView`, add fields and a wiring method, and call it from the constructor after `InstallTextMate`: + +```csharp + private DiffLineNumberMargin? _leftMargin, _rightMargin; +``` + +```csharp + /// The margin's column layout depends on split vs unified, so it is rebuilt rather than + /// reconfigured whenever the layout changes. + private void RebuildMargins() + { + if (_leftMargin is not null) LeftEditor.TextArea.LeftMargins.Remove(_leftMargin); + if (_rightMargin is not null) RightEditor.TextArea.LeftMargins.Remove(_rightMargin); + + var foreground = Brush("TextFaintBrush", Color.Parse("#80FFFFFF")); + var typeface = new Typeface(LeftEditor.FontFamily); + + _leftMargin = new DiffLineNumberMargin(LeftRow, showOld: true, showNew: !IsSplit, + foreground, typeface, LeftEditor.FontSize); + LeftEditor.TextArea.LeftMargins.Insert(0, _leftMargin); + + _rightMargin = new DiffLineNumberMargin(RightRow, showOld: false, showNew: true, + foreground, typeface, RightEditor.FontSize); + RightEditor.TextArea.LeftMargins.Insert(0, _rightMargin); + } + + private IBrush Brush(string key, Color fallback) => + this.TryGetResource(key, ActualThemeVariant, out var value) && value is IBrush brush + ? brush + : new SolidColorBrush(fallback); +``` + +Call `RebuildMargins()` at the end of `Rebuild()`, before `ApplyWrap()`. + +- [ ] **Step 3: Verify the build** + +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +``` + +Expected: build succeeded. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs +git commit -m "feat(diff): draw old and new line numbers in a custom margin" -- src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs +``` + +--- + +## Task 7: Line and word background renderers + +**Files:** +- Modify: `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs` + +Reference: `MergeBlockRenderer` in `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs:432-468` shows the `IBackgroundRenderer` + `BackgroundGeometryBuilder` pattern used here. + +- [ ] **Step 1: Add the renderer types** + +In `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs`, add these nested types: + +```csharp + /// Tints whole rows by their diff role. + private sealed class DiffLineRenderer : IBackgroundRenderer + { + private readonly Func _rows; + private readonly IBrush _add, _del, _filler, _gap; + + public DiffLineRenderer(Func rows, IBrush add, IBrush del, IBrush filler, IBrush gap) + { + _rows = rows; _add = add; _del = del; _filler = filler; _gap = gap; + } + + public KnownLayer Layer => KnownLayer.Background; + + public void Draw(TextView textView, DrawingContext drawingContext) + { + if (!textView.VisualLinesValid) return; + foreach (var visualLine in textView.VisualLines) + { + var row = _rows(visualLine.FirstDocumentLine.LineNumber); + var brush = row?.Kind switch + { + AlignedSide.Add => _add, + AlignedSide.Del => _del, + AlignedSide.Filler => _filler, + AlignedSide.Gap => _gap, + _ => null, + }; + if (brush is null) continue; + + var top = visualLine.VisualTop - textView.ScrollOffset.Y; + drawingContext.FillRectangle(brush, new Rect(0, top, textView.Bounds.Width, visualLine.Height)); + } + } + } + + /// Tints the changed character ranges inside a row, on top of the row tint. + private sealed class WordDiffRenderer : IBackgroundRenderer + { + private readonly Func _rows; + private readonly IBrush _add, _del; + + public WordDiffRenderer(Func rows, IBrush add, IBrush del) + { + _rows = rows; _add = add; _del = del; + } + + // Above the line tint but still behind the text. + public KnownLayer Layer => KnownLayer.Selection; + + public void Draw(TextView textView, DrawingContext drawingContext) + { + if (!textView.VisualLinesValid) return; + foreach (var visualLine in textView.VisualLines) + { + var documentLine = visualLine.FirstDocumentLine; + var row = _rows(documentLine.LineNumber); + if (row is null || row.Spans.Count == 0) continue; + + var brush = row.Kind == AlignedSide.Add ? _add : _del; + foreach (var span in row.Spans) + { + var offset = documentLine.Offset + span.Start; + // Spans are computed against the row text; clamp in case the document + // and the row lookup ever disagree rather than drawing past the line. + if (offset < documentLine.Offset || offset + span.Length > documentLine.EndOffset) continue; + + var builder = new BackgroundGeometryBuilder { AlignToWholePixels = true, CornerRadius = 2 }; + builder.AddSegment(textView, new Seg(offset, span.Length)); + if (builder.CreateGeometry() is { } geometry) + drawingContext.DrawGeometry(brush, null, geometry); + } + } + } + } + + /// A minimal for geometry queries. + private readonly struct Seg : ISegment + { + public Seg(int offset, int length) { Offset = offset; Length = length; } + public int Offset { get; } + public int Length { get; } + public int EndOffset => Offset + Length; + } +``` + +Add `using AvaloniaEdit.Document;` for `ISegment`. + +- [ ] **Step 2: Install the renderers** + +Renderers are added once per editor in the constructor — they read through the `LeftRow`/`RightRow` delegates, so a rebuild only needs an invalidate. Add to `DiffTextView`: + +```csharp + private void InstallRenderers() + { + var add = Brush("RunningTintBrush", Color.Parse("#1F7C9166")); + var del = Brush("ErrorTintBrush", Color.Parse("#1FC87060")); + var filler = Brush("DiffFillerBrush", Color.Parse("#0AFFFFFF")); + var gap = Brush("DiffGapBrush", Color.Parse("#14FFFFFF")); + var wordAdd = Brush("DiffWordAddBrush", Color.Parse("#556FA86B")); + var wordDel = Brush("DiffWordDelBrush", Color.Parse("#55C87060")); + + LeftEditor.TextArea.TextView.BackgroundRenderers.Add(new DiffLineRenderer(LeftRow, add, del, filler, gap)); + LeftEditor.TextArea.TextView.BackgroundRenderers.Add(new WordDiffRenderer(LeftRow, wordAdd, wordDel)); + RightEditor.TextArea.TextView.BackgroundRenderers.Add(new DiffLineRenderer(RightRow, add, del, filler, gap)); + RightEditor.TextArea.TextView.BackgroundRenderers.Add(new WordDiffRenderer(RightRow, wordAdd, wordDel)); + } + + private void InvalidateRenderers() + { + LeftEditor.TextArea.TextView.InvalidateVisual(); + RightEditor.TextArea.TextView.InvalidateVisual(); + } +``` + +Call `InstallRenderers()` in the constructor after `InstallTextMate`, and `InvalidateRenderers()` at the end of `Rebuild()`. + +- [ ] **Step 3: Verify the build** + +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +``` + +Expected: build succeeded. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs +git commit -m "feat(diff): tint diff rows and changed words via background renderers" -- src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs +``` + +--- + +## Task 8: Synced scrolling between the panes + +**Files:** +- Modify: `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs` + +With wrap off both panes have identical line heights, so syncing the pixel offset keeps rows exactly aligned. With wrap on the heights diverge; syncing the first visible document line instead keeps the top of the viewport aligned and lets rows drift downward, which is the accepted limitation from the spec. + +Reference: `HookScrollSync` / `OnPaneScroll` in `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs:353-374`. + +- [ ] **Step 1: Add scroll sync** + +In `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs` add these usings: + +```csharp +using Avalonia.Threading; +using Avalonia.VisualTree; +``` + +and add to `DiffTextView`: + +```csharp + private ScrollViewer? _leftScroll, _rightScroll; + private bool _scrollHooked; + private bool _syncing; + + /// The editors' ScrollViewers only exist once the template has been applied. + private void HookScrollSync() + { + if (_scrollHooked) return; + _scrollHooked = true; + Dispatcher.UIThread.Post(() => + { + _leftScroll = LeftEditor.FindDescendantOfType(); + _rightScroll = RightEditor.FindDescendantOfType(); + if (_leftScroll is not null) _leftScroll.ScrollChanged += OnPaneScroll; + if (_rightScroll is not null) _rightScroll.ScrollChanged += OnPaneScroll; + }, DispatcherPriority.Loaded); + } + + private void OnPaneScroll(object? sender, ScrollChangedEventArgs e) + { + if (_syncing || !IsSplit || _leftScroll is null || _rightScroll is null) return; + var fromLeft = ReferenceEquals(sender, _leftScroll); + var source = fromLeft ? _leftScroll : _rightScroll; + var target = fromLeft ? _rightScroll : _leftScroll; + var targetEditor = fromLeft ? RightEditor : LeftEditor; + var sourceEditor = fromLeft ? LeftEditor : RightEditor; + + _syncing = true; + try + { + if (WrapLines) + { + // Line heights differ once lines wrap, so anchor on the top visible line. + var line = sourceEditor.TextArea.TextView.GetDocumentLineByVisualTop( + sourceEditor.TextArea.TextView.ScrollOffset.Y)?.LineNumber; + if (line is { } l && l >= 1 && l <= targetEditor.Document.LineCount) + targetEditor.ScrollToLine(l); + } + else if (Math.Abs(target.Offset.Y - source.Offset.Y) > 0.5) + { + target.Offset = new Vector(target.Offset.X, source.Offset.Y); + } + } + finally { _syncing = false; } + } +``` + +Call `HookScrollSync()` at the end of the constructor. + +- [ ] **Step 2: Verify the build** + +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +``` + +Expected: build succeeded. If `GetDocumentLineByVisualTop` is not present on this AvaloniaEdit version, substitute `sourceEditor.TextArea.TextView.VisualLines is { Count: > 0 } lines ? lines[0].FirstDocumentLine.LineNumber : (int?)null`. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs +git commit -m "feat(diff): sync vertical scrolling across the split panes" -- src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs +``` + +--- + +## Task 9: Wire the toggles and the Files-mode pane + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs` +- Modify: `src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml` +- Modify: `src/ClaudeDo.Localization/locales/en.json`, `src/ClaudeDo.Localization/locales/de.json` + +`DiffViewerViewModel` is registered transient in `src/ClaudeDo.App/Program.cs:132`, and `AppSettings` is a singleton (`:86`), so the new constructor parameter resolves without any DI change. + +- [ ] **Step 1: Add the toggle state to the ViewModel** + +In `src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs`, add the field and extend the constructor: + +```csharp + private readonly AppSettings _settings; +``` + +```csharp + public DiffViewerViewModel(GitService git, IWorkerClient worker, AppSettings settings) + { + _git = git; + _worker = worker; + _settings = settings; + _isSplitView = string.Equals(settings.DiffViewMode, "split", StringComparison.OrdinalIgnoreCase); + _wrapLines = settings.DiffWrapLines; + } +``` + +Add the observable properties next to the other right-pane state: + +```csharp + // ── View toggles (persisted to ui.config.json) ────────────────────────── + [ObservableProperty] private bool _isSplitView; + [ObservableProperty] private bool _wrapLines; + + partial void OnIsSplitViewChanged(bool value) + { + _settings.DiffViewMode = value ? "split" : "unified"; + PersistViewPreferences(); + } + + partial void OnWrapLinesChanged(bool value) + { + _settings.DiffWrapLines = value; + PersistViewPreferences(); + } + + /// A failed preference write must never take the diff viewer down with it; the toggle + /// still works for this session, it just won't survive a restart. + private void PersistViewPreferences() + { + try { _settings.Save(); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +``` + +Add `using ClaudeDo.Ui;` if the namespace is not already in scope (`AppSettings` lives in `ClaudeDo.Ui`). + +- [ ] **Step 2: Add the toolbar and swap the Files pane** + +In `src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml`, insert a new top-docked toolbar immediately after the opening `` (line 31), before the planning toolbar: + +```xml + + + + + +``` + +Replace the Files-mode diff pane (lines 152-155) — the outer `ScrollViewer` goes away because the editor scrolls itself: + +```xml + +``` + +- [ ] **Step 3: Add the locale keys** + +In `src/ClaudeDo.Localization/locales/en.json`, extend the `modals.diff` object (line 354): + +```json + "splitView": "Side by side", + "wrapLines": "Wrap lines" +``` + +In `src/ClaudeDo.Localization/locales/de.json`, the same keys in the matching `modals.diff` object: + +```json + "splitView": "Nebeneinander", + "wrapLines": "Zeilenumbruch" +``` + +- [ ] **Step 4: Verify the build and the locale parity test** + +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release +``` + +Expected: build succeeded; all tests pass. + +`tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs` constructs the ViewModel at lines 39, 52, 107, 127, 144, 165, 187 and 208 — all eight break on the new parameter. Add a helper to that class and use it at every site, so no test can ever write to the real config: + +```csharp + // A throwaway config path — the view toggles call Save(), and a test must never + // overwrite the developer's real ~/.todo-app/ui.config.json. + private static AppSettings TestSettings() => new() + { + ConfigPath = Path.Combine(Path.GetTempPath(), $"claudedo-uicfg-{Guid.NewGuid():N}.json"), + }; +``` + +e.g. `var vm = new DiffViewerViewModel(null!, new FakePlanningWorker(), TestSettings());` + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs +git commit -m "feat(diff): add persisted side-by-side and wrap toggles to the diff viewer" -- src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs +``` + +--- + +## Task 10: Planning mode, remove the old renderer, update docs + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs` +- Modify: `src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml` +- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs` +- Delete: `src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml`, `src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml.cs` +- Modify: `src/ClaudeDo.Ui/CLAUDE.md`, `docs/explore-notes/review-merge.md` + +Planning mode currently flattens every file into one line stream. One editor can carry only one TextMate grammar, so highlighting requires one editor per file. Verified before planning: `DiffLines` and `UnifiedDiffParser.Flatten` have no other consumer (`DiffViewerViewModel.cs:55,221-223` only). + +- [ ] **Step 1: Replace `DiffLines` with `PlanningFiles`** + +In `src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs`, replace the `DiffLines` collection (line 55): + +```csharp + // Planning mode: one entry per file so each gets its own editor and grammar. + public ObservableCollection PlanningFiles { get; } = new(); +``` + +and replace `OnDisplayedDiffChanged` (lines 219-224): + +```csharp + partial void OnDisplayedDiffChanged(string value) + { + PlanningFiles.Clear(); + foreach (var file in UnifiedDiffParser.Parse(value)) + PlanningFiles.Add(file); + } +``` + +- [ ] **Step 2: Replace the planning pane** + +In `src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml`, add `x:Name="DiffRoot"` to the root `` element, then replace the planning pane (lines 159-164): + +```xml + + + + + + + + + + + + + + + + + +``` + +**Known risk — verify this one by running the app.** A `TextEditor` inside an `ItemsControl` inside a `ScrollViewer` is measured with unbounded height. If it collapses to zero height or shows its own scrollbar instead of growing, the fallback is to give `DiffTextView` an explicit height: add a `MeasureOverride` that returns `rowCount * LeftEditor.TextArea.TextView.DefaultLineHeight + Padding`, using the row count from `_aligned`. Do not paper over it with a fixed pixel height. + +- [ ] **Step 3: Remove `Flatten` and delete `DiffLinesView`** + +Delete the `Flatten` method from `src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs` (lines 135-148), then: + +```bash +git rm src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml.cs +``` + +`DiffLineKind.File` stays on the model — `DiffAlignment` still skips it defensively and removing an enum member is unrelated churn. + +- [ ] **Step 4: Update the docs** + +In `src/ClaudeDo.Ui/CLAUDE.md`: +- `Views/Controls/` list (line 34): replace `DiffLinesView` with `DiffTextView`. +- "Diff & Conflicts" section (line 71): replace `` `DiffLinesView` `` with a sentence naming the new stack — `DiffAlignment` (pure row alignment + word diff) and `DiffTextView` (AvaloniaEdit, TextMate highlighting, unified/split, wrap), and note that both toggles persist to `ui.config.json`. + +In `docs/explore-notes/review-merge.md`: update the diff-stack description to the same effect and bump its "verified against" commit to the commit produced by this task. + +- [ ] **Step 5: Verify the full build and test suite** + +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release +``` + +Expected: both builds succeed; both test projects pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/review-merge.md +git commit -m "refactor(diff): render planning mode per file and retire DiffLinesView" -- src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml.cs src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/review-merge.md +``` + +--- + +## Manual verification (cannot be automated — the user runs these) + +Nothing below is covered by a test. Do not claim the feature works until the user confirms. + +- [ ] Files mode, unified: syntax colours visible, add/delete tints legible on top of them, old+new line numbers aligned. +- [ ] Files mode, split: left pane is the old state, right pane the new state; fillers and the `⋯` gap rows read correctly. +- [ ] Word diff: a one-token change highlights only that token on both sides. +- [ ] Wrap off, split: the panes stay pixel-aligned while scrolling either one. +- [ ] Wrap on, split: long lines wrap; the top of the viewport stays aligned. +- [ ] Planning mode with a multi-file subtask: every file renders, each with its own highlighting, and the editors size to their content (see the Task 10 risk note). +- [ ] A file type with no TextMate grammar (e.g. `.slnx`) renders as plain text without throwing. +- [ ] Toggle both switches, close the app, reopen: the choices survived. diff --git a/docs/superpowers/plans/2026-08-07-handler-run-links.md b/docs/superpowers/plans/2026-08-07-handler-run-links.md new file mode 100644 index 00000000..141b4672 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-handler-run-links.md @@ -0,0 +1,939 @@ +# Handler-Run Links 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:** A "Let Claude handle it" run records which tasks it processed, shows them as a list on the handler task's detail pane, and wears a HANDLER badge instead of MANUAL. + +**Architecture:** One new nullable column `TaskEntity.HandlerTaskId` (1:n, last run wins) stamped at handler-task creation from the selection the UI already passes down. The badge is a display-only computed property on `TaskRowViewModel`, driven by the existing `HandlerBaseCommit`. The panel reuses `ChildOutcomeRowViewModel` and the existing refresh path. + +**Tech Stack:** .NET 8, EF Core (SQLite), Avalonia 12 + CommunityToolkit.Mvvm, xUnit. + +**Spec:** `docs/superpowers/specs/2026-08-07-handler-run-links-design.md` + +--- + +## File Structure + +**Modified:** +- `src/ClaudeDo.Data/Models/TaskEntity.cs` — new `HandlerTaskId` property +- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — column mapping + index +- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — `SetHandlerTaskIdAsync` +- `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` — stamp after creating the handler task +- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — `HandlerBaseCommit`, `IsHandlerRun`, `HandlerBadge`, `ManualBadge` precedence +- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — HANDLER badge border +- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` — `HandlerBadgeBrush` + `Border.badge.handler` +- `src/ClaudeDo.Localization/locales/en.json` + `de.json` — `tasks.badgeHandler`, `tasks.handlerTip` +- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — `HandledTasks` collection, loader, clear, refresh +- `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — HANDLED TASKS panel +- `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Ui/CLAUDE.md`, `docs/explore-notes/conpty-sessions.md` — docs + +**Created:** +- `src/ClaudeDo.Data/Migrations/_AddHandlerTaskId.cs` (+ Designer, + snapshot update) — generated +- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs` +- `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs` +- `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs` + +--- + +## Task 1: Data — `HandlerTaskId` column and migration + +**Files:** +- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:60-61` +- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:96-97` and `:127-131` +- Create: `src/ClaudeDo.Data/Migrations/_AddHandlerTaskId.cs` (generated) + +- [ ] **Step 1: Add the property** + +In `src/ClaudeDo.Data/Models/TaskEntity.cs`, directly after the existing `HandlerHeadCommit` line (`public string? HandlerHeadCommit { get; set; }`), add: + +```csharp + + // Id of the "list handler" run task that processed this task ("Let Claude handle it"). + // 1:n and last-run-wins -- a second handler run over the same task overwrites it. Deliberately + // NOT ParentTaskId: that is the planning-child relation and drives the indented tree rendering. + // No FK: a deleted handler task must not cascade into the tasks it merely touched. + public string? HandlerTaskId { get; set; } +``` + +- [ ] **Step 2: Map the column and index it** + +In `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`, after the line +`builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");` add: + +```csharp + builder.Property(t => t.HandlerTaskId).HasColumnName("handler_task_id"); +``` + +At the end of `Configure`, after the line +`builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");` add: + +```csharp + builder.HasIndex(t => t.HandlerTaskId).HasDatabaseName("idx_tasks_handler_task_id"); +``` + +Do **not** add a `HasOne`/`HasForeignKey` relationship — the column is intentionally FK-less. + +- [ ] **Step 3: Generate the migration** + +Run from the repo root: + +```bash +dotnet ef migrations add AddHandlerTaskId --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj +``` + +Expected: creates `src/ClaudeDo.Data/Migrations/_AddHandlerTaskId.cs` + `.Designer.cs` and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method must contain exactly one `AddColumn(name: "handler_task_id", table: "tasks", nullable: true)` and one `CreateIndex(name: "idx_tasks_handler_task_id", table: "tasks", column: "handler_task_id")`. If it contains anything else, another agent's uncommitted model change leaked in — delete the migration, coordinate, retry. + +If `dotnet ef` is unavailable, hand-author the migration + Designer mirroring +`src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.cs`, and add +`Property("HandlerTaskId").HasColumnType("TEXT").HasColumnName("handler_task_id");` +plus the index to the `TaskEntity` builder in `ClaudeDoDbContextModelSnapshot.cs`. + +- [ ] **Step 4: Build** + +Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release` +Expected: `Build succeeded`, 0 errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations +git commit -- src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations -m "feat(data): add handler_task_id to link handled tasks to their handler run" +``` + +⚠️ Always commit with explicit paths (`git commit -- `), never a bare `git commit` — the +main checkout is shared with concurrent sessions. + +--- + +## Task 2: Data — `SetHandlerTaskIdAsync` repository method + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (after `SetHandlerHeadCommitAsync`, currently `:394-403`) +- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Repositories; + +/// Covers the handler-run link: SetHandlerTaskIdAsync stamps the tasks a "Let Claude handle it" +/// run processed, so the handler task's detail pane can list them after the run. +public sealed class TaskRepositoryHandlerLinkTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + + public TaskRepositoryHandlerLinkTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + } + + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + } + + private async Task CreateListAsync() + { + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity + { + Id = listId, + Name = "Test List", + CreatedAt = DateTime.UtcNow, + }); + return listId; + } + + private async Task AddTaskAsync(string listId) + { + var id = Guid.NewGuid().ToString(); + await _tasks.AddAsync(new TaskEntity + { + Id = id, + ListId = listId, + Title = "T", + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + }); + return id; + } + + [Fact] + public async Task SetHandlerTaskIdAsync_StampsAllGivenTasks() + { + var listId = await CreateListAsync(); + var a = await AddTaskAsync(listId); + var b = await AddTaskAsync(listId); + var handlerId = await AddTaskAsync(listId); + + var affected = await _tasks.SetHandlerTaskIdAsync(new[] { a, b }, handlerId); + + Assert.Equal(2, affected); + Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId); + Assert.Equal(handlerId, (await _tasks.GetByIdAsync(b))!.HandlerTaskId); + Assert.Null((await _tasks.GetByIdAsync(handlerId))!.HandlerTaskId); + } + + [Fact] + public async Task SetHandlerTaskIdAsync_IgnoresUnknownIds() + { + var listId = await CreateListAsync(); + var a = await AddTaskAsync(listId); + var handlerId = await AddTaskAsync(listId); + + var affected = await _tasks.SetHandlerTaskIdAsync( + new[] { a, "does-not-exist" }, handlerId); + + Assert.Equal(1, affected); + Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId); + } + + [Fact] + public async Task SetHandlerTaskIdAsync_SecondRunOverwrites() + { + var listId = await CreateListAsync(); + var a = await AddTaskAsync(listId); + var firstHandler = await AddTaskAsync(listId); + var secondHandler = await AddTaskAsync(listId); + + await _tasks.SetHandlerTaskIdAsync(new[] { a }, firstHandler); + await _tasks.SetHandlerTaskIdAsync(new[] { a }, secondHandler); + + Assert.Equal(secondHandler, (await _tasks.GetByIdAsync(a))!.HandlerTaskId); + } + + [Fact] + public async Task SetHandlerTaskIdAsync_EmptyList_IsNoOp() + { + var listId = await CreateListAsync(); + var handlerId = await AddTaskAsync(listId); + + var affected = await _tasks.SetHandlerTaskIdAsync(Array.Empty(), handlerId); + + Assert.Equal(0, affected); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"` +Expected: compile error — `TaskRepository` does not contain a definition for `SetHandlerTaskIdAsync`. + +- [ ] **Step 3: Implement the method** + +In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, directly after `SetHandlerHeadCommitAsync`, add: + +```csharp + // Links the tasks a "list handler" run processed back to the handler's own task, so the + // handler's detail pane can list them after the run. Stamped from the user's selection at + // creation time -- that way tasks the handler later cancels as duplicates stay visible. + // Unknown ids are silently skipped. Returns the number of rows actually stamped. + public async Task SetHandlerTaskIdAsync( + IReadOnlyList taskIds, + string handlerTaskId, + CancellationToken ct = default) + { + if (taskIds.Count == 0) return 0; + + var ids = taskIds.Where(id => id != handlerTaskId).Distinct().ToList(); + if (ids.Count == 0) return 0; + + return await _context.Tasks + .Where(t => ids.Contains(t.Id)) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.HandlerTaskId, handlerTaskId), ct); + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"` +Expected: `Passed! - Failed: 0, Passed: 4`. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs +git commit -- src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs -m "feat(data): add SetHandlerTaskIdAsync to stamp handled tasks" +``` + +--- + +## Task 3: Worker — stamp the selection when the handler task is created + +**Files:** +- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:409-450` +- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` (append a `[Fact]` in the `── CreateMergeHelperTaskAsync ──` region, currently starting at `:747`) + +Note: `CreateMergeHelperTaskAsync` already receives `IReadOnlyList taskIds` — the UI → +`IWorkerClient` → `WorkerHub` chain needs **no** change. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` inside the same test class, after the existing `CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit` test: + +```csharp + [Fact] + public async Task CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = CreateRepo(); + var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha"); + var t1 = Guid.NewGuid().ToString(); + var t2 = Guid.NewGuid().ToString(); + await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task"); + await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task"); + + var svc = BuildService(); + var handlerId = await svc.CreateMergeHelperTaskAsync( + new[] { t1, t2 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None); + + using var readCtx = _db.CreateContext(); + var tasks = new TaskRepository(readCtx); + Assert.Equal(handlerId, (await tasks.GetByIdAsync(t1))!.HandlerTaskId); + Assert.Equal(handlerId, (await tasks.GetByIdAsync(t2))!.HandlerTaskId); + // The handler never links to itself. + Assert.Null((await tasks.GetByIdAsync(handlerId))!.HandlerTaskId); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks"` +Expected: FAIL — `Assert.Equal() Failure: Values differ … Actual: null`. + +- [ ] **Step 3: Stamp the selection** + +In `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`, in `CreateMergeHelperTaskAsync`, replace: + +```csharp + await taskRepo.AddAsync(handlerTask, ct); + + return handlerTask.Id; +``` + +with: + +```csharp + await taskRepo.AddAsync(handlerTask, ct); + + // Link the selection back to this run BEFORE the session starts: the handler cancels + // duplicates in phase 1, and those still belong in the "what was this run supposed to do" + // list. Stamping later (e.g. at handoff) would lose them. + await taskRepo.SetHandlerTaskIdAsync(taskIds, handlerTask.Id, ct); + + return handlerTask.Id; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync"` +Expected: `Passed! - Failed: 0` (all five `CreateMergeHelperTaskAsync` tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +git commit -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs -m "feat(handler): link the selected tasks to the handler run task" +``` + +--- + +## Task 4: Ui — HANDLER badge instead of MANUAL + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:40,51,234-240,308-329` +- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml:141-144` +- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml:114-118` and `:987-990` +- Modify: `src/ClaudeDo.Localization/locales/en.json:163-164`, `src/ClaudeDo.Localization/locales/de.json:163-164` +- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`: + +```csharp +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Islands; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +/// A "list handler" host task is IsManual=true so automation skips it, but MANUAL reads wrong on +/// it -- the HANDLER badge must win and MANUAL must disappear. +public class TaskRowViewModelHandlerBadgeTests +{ + [Fact] + public void HandlerTask_ShowsHandlerBadge_AndSuppressesManualBadge() + { + var row = new TaskRowViewModel { Id = "t1" }; + row.IsManual = true; + row.HandlerBaseCommit = "base123"; + + Assert.True(row.IsHandlerRun); + Assert.NotNull(row.HandlerBadge); + Assert.Null(row.ManualBadge); + } + + [Fact] + public void PlainManualTask_StillShowsManualBadge() + { + var row = new TaskRowViewModel { Id = "t2" }; + row.IsManual = true; + + Assert.False(row.IsHandlerRun); + Assert.Null(row.HandlerBadge); + Assert.NotNull(row.ManualBadge); + } + + [Fact] + public void UpdateFromEntity_MirrorsHandlerBaseCommit() + { + var row = new TaskRowViewModel { Id = "t3" }; + row.UpdateFromEntity(new TaskEntity + { + Id = "t3", + ListId = "l1", + Title = "List handler: Alpha", + Status = TaskStatus.Idle, + IsManual = true, + HandlerBaseCommit = "base123", + CreatedAt = DateTime.UtcNow, + }); + + Assert.Equal("base123", row.HandlerBaseCommit); + Assert.True(row.IsHandlerRun); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"` +Expected: compile error — `TaskRowViewModel` has no `HandlerBaseCommit` / `IsHandlerRun` / `HandlerBadge`. + +- [ ] **Step 3: Add the properties** + +In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`, after the `_isManual` field +declaration (`[ObservableProperty] private bool _isManual;`), add: + +```csharp + // Mirror of TaskEntity.HandlerBaseCommit -- non-null marks this row as a "list handler" run + // host task ("Let Claude handle it"), which wears HANDLER instead of MANUAL. + [ObservableProperty] private string? _handlerBaseCommit; +``` + +Replace the `ManualBadge` line (currently `public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;`) with: + +```csharp + public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit); + + public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null; + + // HANDLER outranks MANUAL: a handler host task is IsManual only so automation skips it, and + // "MANUAL" would read as a hand-written reminder. The two badges never show together. + public bool ShowManualBadge => IsManual && !IsHandlerRun; + + public string? ManualBadge => ShowManualBadge ? Loc.T("tasks.badgeManual") : null; +``` + +Add a change hook next to the existing `OnIsManualChanged` partial method: + +```csharp + partial void OnHandlerBaseCommitChanged(string? value) + { + OnPropertyChanged(nameof(IsHandlerRun)); + OnPropertyChanged(nameof(HandlerBadge)); + OnPropertyChanged(nameof(ShowManualBadge)); + OnPropertyChanged(nameof(ManualBadge)); + } +``` + +Inside the existing `OnIsManualChanged`, next to the existing `OnPropertyChanged(nameof(ManualBadge));` line, add: + +```csharp + OnPropertyChanged(nameof(ShowManualBadge)); +``` + +In `UpdateFromEntity`, after the line `IsManual = t.IsManual;` add: + +```csharp + HandlerBaseCommit = t.HandlerBaseCommit; +``` + +Also add `HandlerBadge` to `RefreshLocalized`, next to the existing `PlanningBadge` line: + +```csharp + OnPropertyChanged(nameof(HandlerBadge)); + OnPropertyChanged(nameof(ManualBadge)); +``` + +- [ ] **Step 4: Add the locale keys** + +In `src/ClaudeDo.Localization/locales/en.json`, after `"manualTip": ...` (line 164) add: + +```json + "badgeHandler": "HANDLER", + "handlerTip": "Handler run — see the tasks it processed in the detail pane", +``` + +In `src/ClaudeDo.Localization/locales/de.json`, after `"manualTip": ...` (line 164) add: + +```json + "badgeHandler": "HANDLER", + "handlerTip": "Handler-Run — die bearbeiteten Tasks stehen im Detailbereich", +``` + +- [ ] **Step 5: Add the badge style and brush** + +In `src/ClaudeDo.Ui/Design/IslandStyles.axaml`, after the line +`` add: + +```xml + +``` + +After the existing `Border.badge.manual` style block add: + +```xml + + +``` + +- [ ] **Step 6: Render the badge** + +In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, replace the manual badge block (lines 141-144): + +```xml + + + +``` + +with: + +```xml + + + + + + +``` + +Only the `IsVisible` binding changed on the manual border (`IsManual` → `ShowManualBadge`); the +handler border is new. No converter is needed — `ShowManualBadge` is already a `bool`. + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"` +Expected: `Passed! - Failed: 0, Passed: 3`. + +Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release` +Expected: `Passed! - Failed: 0` (en/de key parity). + +Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` +Expected: `Build succeeded` — this compiles the AXAML. + +- [ ] **Step 8: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs +git commit -- src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs -m "feat(ui): show a HANDLER badge on list-handler run tasks" +``` + +--- + +## Task 5: Ui — "HANDLED TASKS" panel on the handler's detail pane + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:248-255`, `:581-584`, `:685`, `:814-833` +- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:414-434` +- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +/// Covers the handler-run link: binding a "list handler" host task lists every task stamped with +/// its id, including ones the handler cancelled as duplicates. +public class DetailsIslandHandledTasksTests : IDisposable +{ + private readonly string _dbPath; + + public DetailsIslandHandledTasksTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_handled_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() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() => _create(); + } + + private sealed class NullServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi + { + public Task> ListAsync(DateOnly day) => + Task.FromResult(new List()); + public Task AddAsync(DateOnly day, string text) => + Task.FromResult(null); + public Task UpdateAsync(string id, string text) => Task.CompletedTask; + public Task DeleteAsync(string id) => Task.CompletedTask; + } + + private sealed class FakeWorkerClient : StubWorkerClient + { + public override bool IsConnected => true; + } + + private DetailsIslandViewModel BuildVm() + { + var factory = new TestDbFactory(NewContext); + return new DetailsIslandViewModel( + factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator()); + } + + [Fact] + public async Task Bind_HandlerTask_ListsHandledTasksWithTheirStatus() + { + const string listId = "list-1"; + const string handlerId = "handler-task-1"; + + await using (var ctx = NewContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = @"C:\repo", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity + { + Id = handlerId, ListId = listId, Title = "List handler: L", + Status = TaskStatus.WaitingForReview, IsManual = true, + HandlerBaseCommit = "base123", HandlerHeadCommit = "head456", + CreatedAt = DateTime.UtcNow, + }); + ctx.Tasks.Add(new TaskEntity + { + Id = "done-1", ListId = listId, Title = "Merged task", + Status = TaskStatus.Done, HandlerTaskId = handlerId, + SortOrder = 0, CreatedAt = DateTime.UtcNow, + }); + ctx.Tasks.Add(new TaskEntity + { + Id = "dupe-1", ListId = listId, Title = "Duplicate the handler cancelled", + Status = TaskStatus.Cancelled, HandlerTaskId = handlerId, + SortOrder = 1, CreatedAt = DateTime.UtcNow, + }); + ctx.Tasks.Add(new TaskEntity + { + Id = "unrelated-1", ListId = listId, Title = "Not part of the run", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + var vm = BuildVm(); + vm.Bind(new TaskRowViewModel { Id = handlerId, Status = TaskStatus.WaitingForReview }); + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline && vm.HandledTasks.Count == 0) + await Task.Delay(20); + + Assert.Equal(2, vm.HandledTasks.Count); + Assert.True(vm.HasHandledTasks); + Assert.Equal("Merged task", vm.HandledTasks[0].Title); + Assert.Equal(TaskStatus.Done, vm.HandledTasks[0].Status); + Assert.Equal(TaskStatus.Cancelled, vm.HandledTasks[1].Status); + Assert.DoesNotContain(vm.HandledTasks, r => r.Id == "unrelated-1"); + } + + [Fact] + public async Task Bind_PlainTask_HasNoHandledTasks() + { + const string listId = "list-1"; + const string taskId = "plain-1"; + + await using (var ctx = NewContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity + { + Id = taskId, ListId = listId, Title = "Plain", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + var vm = BuildVm(); + vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle }); + await Task.Delay(300); + + Assert.Empty(vm.HandledTasks); + Assert.False(vm.HasHandledTasks); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"` +Expected: compile error — `DetailsIslandViewModel` has no `HandledTasks` / `HasHandledTasks`. + +- [ ] **Step 3: Add the collection** + +In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, after the line +`public ObservableCollection ChildOutcomes { get; } = new();` add: + +```csharp + // Tasks a "list handler" run processed ("Let Claude handle it"), linked via + // TaskEntity.HandlerTaskId. Separate from ChildOutcomes on purpose: that collection is the + // planning/improvement parent's children and feeds the merge card's combined diff, which a + // handler run must not touch (it commits straight to the list's working dir). + public ObservableCollection HandledTasks { get; } = new(); +``` + +After the line `public bool HasChildOutcomes => ChildOutcomes.Count > 0;` add: + +```csharp + public bool HasHandledTasks => HandledTasks.Count > 0; +``` + +- [ ] **Step 4: Clear it on rebind** + +In the same file, in the rebind reset block, after the line `ChildOutcomes.Clear();` add: + +```csharp + HandledTasks.Clear(); +``` + +and after `OnPropertyChanged(nameof(HasChildOutcomes));` in that same block add: + +```csharp + OnPropertyChanged(nameof(HasHandledTasks)); +``` + +- [ ] **Step 5: Load it on bind** + +In the same file, directly after the line `await LoadChildOutcomesAsync(row.Id, ct);` add: + +```csharp + await LoadHandledTasksAsync(row.Id, ct); +``` + +Then add the loader immediately after the closing brace of `LoadChildOutcomesAsync`: + +```csharp + // Tasks stamped with this handler run's id. Ordered like the task list itself so the panel + // reads in the same order the user picked them. + private async System.Threading.Tasks.Task LoadHandledTasksAsync(string handlerTaskId, CancellationToken ct) + { + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var handled = await ctx.Tasks + .AsNoTracking() + .Include(t => t.Worktree) + .Where(t => t.HandlerTaskId == handlerTaskId) + .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) + .ToListAsync(ct); + ct.ThrowIfCancellationRequested(); + if (handled.Count == 0) return; + + HandledTasks.Clear(); + foreach (var h in handled) + HandledTasks.Add(new ChildOutcomeRowViewModel + { + Id = h.Id, + Title = h.Title, + Status = h.Status, + RoadblockCount = h.RoadblockCount, + WorktreeState = h.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active, + }); + OnPropertyChanged(nameof(HasHandledTasks)); + } + catch (OperationCanceledException) { } + catch { /* best-effort */ } + } +``` + +- [ ] **Step 6: Keep the rows live** + +In the same file, in `RefreshChildOutcomeAsync`, replace: + +```csharp + var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId); + if (row is null) return; +``` + +with: + +```csharp + // The same refresh serves both lists: a planning parent's children and a handler run's + // handled tasks. Only one of them can hold a given id. + var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId) + ?? HandledTasks.FirstOrDefault(c => c.Id == childTaskId); + if (row is null) return; +``` + +- [ ] **Step 7: Render the panel** + +In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, directly after the closing +`` of the existing `` block, add: + +```xml + + + + + + + + + + + + + + + +``` + +- [ ] **Step 8: Run the tests to verify they pass** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"` +Expected: `Passed! - Failed: 0, Passed: 2`. + +Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` +Expected: `Build succeeded`. + +- [ ] **Step 9: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs +git commit -- src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs -m "feat(ui): list the tasks a handler run processed on its detail pane" +``` + +--- + +## Task 6: Full verification and docs + +**Files:** +- Modify: `src/ClaudeDo.Data/CLAUDE.md` (TaskEntity field list) +- Modify: `src/ClaudeDo.Ui/CLAUDE.md` (TaskRowViewModel + DetailsIslandViewModel bullets) +- Modify: `docs/explore-notes/conpty-sessions.md` (list handler → host task section) + +- [ ] **Step 1: Run every affected test project** + +```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.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release +``` + +Expected: `Failed: 0` in all four. If a hand-rolled fake in a test project fails to compile, +it is one of the known `IWorkerClient`/ViewModel-ctor fakes — update it; do not skip the test. + +- [ ] **Step 2: Update `src/ClaudeDo.Data/CLAUDE.md`** + +In the `TaskEntity` bullet, append `HandlerTaskId` to the field enumeration (after +`HandlerBaseCommit / HandlerHeadCommit`), and add a sub-bullet under the existing +`HandlerBaseCommit`/`HandlerHeadCommit` sub-bullet: + +```markdown + - `HandlerTaskId` = back-link from a task to the **list handler run** that processed it (1:n, last run wins, no FK). Stamped from the user's selection when the handler task is created, so tasks the handler later cancels as duplicates stay listed. Deliberately not `ParentTaskId` — that is the planning-child relation and drives the indented tree. +``` + +- [ ] **Step 3: Update `src/ClaudeDo.Ui/CLAUDE.md`** + +In the `DetailsIslandViewModel` bullet, after the `ChildOutcomes` mention, add +`, plus `HandledTasks` (tasks a list-handler run processed, via `HandlerTaskId`)`. + +In the `TaskRowViewModel` sentence, after the `IsManual` clause, add +`, `IsHandlerRun` (→ HANDLER badge, which outranks MANUAL)`. + +- [ ] **Step 4: Update `docs/explore-notes/conpty-sessions.md`** + +In the "The host task and its commit range" section, add after the existing description: + +```markdown +`CreateMergeHelperTaskAsync` also stamps `TaskEntity.HandlerTaskId` on every selected task +(`TaskRepository.SetHandlerTaskIdAsync`) before the session starts, so the handler task's detail +pane can list what the run was meant to process — including tasks phase 1 cancels as duplicates. +The handler never links to itself. +``` + +Bump that note's "verified against" commit line to the current HEAD. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md +git commit -- src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md -m "docs(handler): document the handler-run task link" +``` + +- [ ] **Step 6: Report the visual-verification gap** + +The build and tests cannot confirm any of this renders correctly. Explicitly hand these to Mika: + +1. HANDLER badge colour and legibility on a handler task row (light **and** dark theme), and that MANUAL is gone from that row while still present on a normal manual reminder. +2. The HANDLED TASKS panel on the handler task's Session tab: position relative to OUTCOMES, spacing, and behaviour with ~20 handled tasks (scroll). +3. That a real "Let Claude handle it" run over a multi-task selection produces a populated panel after the run, including a phase-1-cancelled duplicate. diff --git a/docs/superpowers/specs/2026-08-07-handler-run-links-design.md b/docs/superpowers/specs/2026-08-07-handler-run-links-design.md new file mode 100644 index 00000000..3702d674 --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-handler-run-links-design.md @@ -0,0 +1,169 @@ +# Handler-Run: Verknüpfung zu den behandelten Tasks + +**Date:** 2026-08-07 +**Status:** Design approved (Mika), implementation pending +**Verified against:** commit `c792765` + +## Problem + +Ein "Let Claude handle it"-Run besitzt seit 2026-08-05 einen echten Task (`IsManual=true`, +`HandlerBaseCommit`/`HandlerHeadCommit`, Diff über Commit-Range). Was fehlt: **welche Tasks der Run +behandelt hat, ist nirgends persistiert.** Die Auswahl lebt nur in der ConPTY-Session und im +Transcript; `HandoffMcpTools.HandoffListHandler` (`src/ClaudeDo.Worker/External/HandoffMcpTools.cs:28-45`) +bekommt `survivingTaskIds` als flüchtige Liste. + +Folge: Nachdem ein Run durch ist und der Diff sichtbar wird, lässt sich nicht mehr nachvollziehen, +*was alles gemacht werden sollte* und *welcher Task was produziert hat*. Duplikate, die der Handler +in Phase 1 gecancelt hat, verschwinden vollständig aus dem Blickfeld. + +Zweitens zeigt der Handler-Task in der Liste das Badge **MANUAL**, weil er `IsManual=true` setzt — +irreführend, denn es ist kein manueller Reminder. + +## Ist-Zustand + +### Es gibt kein Task-Kind + +`TaskEntity` hat **kein `Kind`/`Type`-Enum**. Task-"Arten" sind heute Feld-Kombinationen: + +| Feld | Bedeutung | +|---|---| +| `IsManual` | manueller Reminder — Queue/Daily-Prep/Refine überspringen ihn | +| `ParentTaskId` | Kind einer Planning-/Improvement-Session | +| `PlanningPhase` | Planning-Parent | +| `BlockedByTaskId` | Kettenglied, Queue-Picker überspringt es | +| `HandlerBaseCommit` | worktree-loser List-Handler-Host (`src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`) | + +Ein Handler-Task ist also allein durch `HandlerBaseCommit != null` identifiziert. + +### `ParentTaskId` ist belegt + +`TaskRepository.CreateChildAsync` (`src/ClaudeDo.Data/Repositories/TaskRepository.cs:306`) setzt es +für Planning-Kinder; `TaskRowViewModel.IsChild`/`ShowAsChild` +(`src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:59,66`) hängen daran und rücken die Zeile +im Baum ein. Ein Recycling für Handler→behandelte Tasks würde die Auswahl optisch unter den Handler +schieben und mit echten Planning-Kindern kollidieren. + +### Badge-Infrastruktur existiert + +`TaskRowView.axaml:129-144` rendert DRAFT / PLANNED / PLANNING / MANUAL über +`Border Classes="badge "`. Basis-Style und Varianten liegen in +`src/ClaudeDo.Ui/Design/IslandStyles.axaml:963-990`, die Brushes als theme-fähige Tokens in +`Tokens.axaml`. Loc-Keys: `tasks.badgeManual`, `tasks.manualTip` (en.json:163-164). + +### Kinder-Panel existiert + +`DetailsIslandViewModel.LoadChildOutcomesAsync` +(`src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:704-748`) lädt +`Where(t => t.ParentTaskId == parentTaskId)` in `ChildOutcomes` (`:248`) und rendert pro Zeile +Id/Titel/Status/RoadblockCount/WorktreeState via `ChildOutcomeRowViewModel`; Refresh läuft über +`TaskUpdated`/`WorktreeUpdated` (`:814-833`). + +## Entscheidungen + +| Frage | Entscheidung | Begründung | +|---|---|---| +| Neues `TaskKind`-Enum? | **Nein** | Es gäbe kein Enum zu erweitern — es wäre das erste überhaupt, inkl. Migration und Rückwirkung auf Queue/Filter/UI. Der Bedarf ist eine Beziehung, kein Typ. | +| `ParentTaskId` wiederverwenden? | **Nein** | belegt durch Planning-Kinder, kollidiert mit Einrückungs-Logik | +| 1:n oder n:m? | **1:n**, eine nullable Spalte | Historie "welcher Run hat den Task mal berührt" bringt nichts, wenn ohnehin der letzte Run derjenige ist, dessen Diff man ansieht. Join-Tabelle = doppelter Code für einen Randfall. | +| Wann stempeln? | **Beim Anlegen des Handler-Tasks** | Die UI kennt die Auswahl bereits. Erfasst auch die Tasks, die der Handler in Phase 1 als Duplikat cancelt — genau das "was sollte alles gemacht werden". Ein Stempeln erst in `handoff_list_handler` würde Dedupe-Verlierer verlieren und bei Abbruch vor Phase 2 gar nichts verknüpfen. | +| Umfang der Anzeige | **Nur Liste + Endstatus** | Kein Phasen-Protokoll, kein Per-Task-Diff im Panel — der Diff hängt ohnehin am jeweiligen Task. | + +## Design + +### 1. Daten + +Neue nullable Spalte auf `TaskEntity`: + +```csharp +/// Id des Handler-Task-Runs, der diesen Task behandelt hat (null = keiner). +public string? HandlerTaskId { get; set; } +``` + +Konfiguration in `TaskEntityConfiguration`: `HasIndex(t => t.HandlerTaskId)`, kein FK-Constraint +(konsistent mit `BlockedByTaskId`-Handhabung; ein gelöschter Handler-Task soll die behandelten Tasks +nicht kaskadierend anfassen). EF-Core-Migration `AddHandlerTaskId`. + +Ein zweiter Run über dieselben Tasks überschreibt die Zuordnung — gewollt (1:n). + +### 2. Schreiben + +Die Auswahl wird durchgereicht: UI → `IWorkerClient.CreateMergeHelperTaskAsync` → +`WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:827-838`) → +`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync`. Nach dem Anlegen des Handler-Tasks setzt +eine neue Repository-Methode die Zuordnung in einem Batch-Update: + +```csharp +Task SetHandlerTaskIdAsync(IReadOnlyList taskIds, string handlerTaskId, CancellationToken ct); +``` + +Der Handler-Task selbst bekommt **kein** `HandlerTaskId` (kein Selbstbezug). Unbekannte Ids werden +still übersprungen. + +### 3. Badge + +`TaskRowViewModel`: + +```csharp +public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit); +public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null; +public string? ManualBadge => IsManual && !IsHandlerRun ? Loc.T("tasks.badgeManual") : null; +``` + +`HandlerBaseCommit` muss dafür auf das Row-ViewModel und in dessen Mapping aufgenommen werden. +HANDLER hat Vorrang vor MANUAL — beide Badges nie gleichzeitig. + +In `TaskRowView.axaml` analog zu `:141-144` ein `Border Classes="badge handler"` mit +`ToolTip.Tip="{loc:Tr tasks.handlerTip}"`. In `IslandStyles.axaml` eine `.badge.handler`-Variante +mit `{DynamicResource HandlerBadgeBrush}`, Token in `Tokens.axaml` für Light und Dark. + +Neue Loc-Keys in en.json **und** de.json (Parität ist testgeprüft): + +- `tasks.badgeHandler` — "HANDLER" / "HANDLER" +- `tasks.handlerTip` — "Handler run — lists the tasks it processed" / "Handler-Run — listet die + Tasks, die er bearbeitet hat" + +### 4. Anzeige + +Im Detail-Bereich eines Handler-Tasks eine Liste der behandelten Tasks, parallel zum bestehenden +Kinder-Panel: + +- Neue Collection `HandledTasks` auf `DetailsIslandViewModel`, befüllt von `LoadHandledTasksAsync` + mit `Where(t => t.HandlerTaskId == taskId)`, sortiert wie die Kinder-Liste. +- Zeilen wiederverwenden `ChildOutcomeRowViewModel` (Id, Titel, Status, RoadblockCount, + WorktreeState) — keine neue Row-Klasse. +- Refresh über dieselben `TaskUpdated`-Events wie `ChildOutcomes`; der bestehende + `RefreshChildOutcomeAsync`-Pfad (`:814-833`) wird um die zweite Collection erweitert. +- Sichtbar nur wenn `HandledTasks.Count > 0`. +- **Keine Klick-Interaktion** — das bestehende `ChildOutcomes`-Template ist eine reine Anzeige + (Titel / Roadblock / Status, kein Tapped-Handler). Die neue Liste bleibt identisch; "zum Task + springen" wäre neues Verhalten und ist hier nicht enthalten. + +### 5. Fehlerfälle + +- Handler-Task gelöscht → `HandlerTaskId` der behandelten Tasks zeigt ins Leere; die Tasks bleiben + normal nutzbar, das Panel existiert schlicht nicht mehr. Kein Cleanup nötig. +- Behandelter Task gelöscht → verschwindet aus der Liste (Query läuft live gegen die Tasks). +- Leere Auswahl → kein Stempeln, Panel bleibt unsichtbar. + +## Tests + +| Ebene | Test | +|---|---| +| Data | `SetHandlerTaskIdAsync` stempelt alle übergebenen Ids, ignoriert unbekannte, überschreibt eine vorhandene Zuordnung | +| Worker | `CreateMergeHelperTaskAsync` stempelt die übergebene Auswahl und **nicht** den Handler-Task selbst | +| Ui | `TaskRowViewModel`: HANDLER schlägt MANUAL (`IsManual=true` + `HandlerBaseCommit` gesetzt → nur HANDLER) | +| Ui | `DetailsIslandViewModel`: `HandledTasks` lädt nach `HandlerTaskId`, aktualisiert sich auf `TaskUpdated` | +| Localization | Parität en/de — deckt der bestehende Test automatisch ab | + +## Bewusst nicht enthalten + +- Kein `TaskKind`-Enum. +- Keine n:m-Historie über mehrere Runs. +- Kein Phasen-Protokoll (Dedupe-Begründungen, Umformulierungen) — nur das Ergebnis. +- Kein Per-Task-Diff im Panel; der Diff bleibt am jeweiligen Task. +- Kein Badge auf den *behandelten* Tasks. + +## Offen + +- **Sichtprüfung durch Mika:** Badge-Farbe im Light- und Dark-Theme, Position des Panels im + Detail-Bereich, Verhalten bei vielen behandelten Tasks (Scroll). diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index ba4f16a7..e00d3147 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -48,7 +48,7 @@ subfolder within their area; the namespace stays the area namespace. - **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle. - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. -- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Two hard conventions** (both test-enforced): every optional parameter needs a C# default value, and no tool returns bare `Task`/a nullable payload. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md). +- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Two hard conventions** (both test-enforced): every optional parameter needs a C# default value, and no tool returns bare `Task`/a nullable payload. **Tool-description style** (not test-enforced) is documented in `External/McpToolDocs.cs`, which also holds the shared boilerplate clauses — read it before adding or editing a tool description. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md). ## Status Model diff --git a/src/ClaudeDo.Worker/External/AgentMcpTools.cs b/src/ClaudeDo.Worker/External/AgentMcpTools.cs index 80509602..66502d2e 100644 --- a/src/ClaudeDo.Worker/External/AgentMcpTools.cs +++ b/src/ClaudeDo.Worker/External/AgentMcpTools.cs @@ -12,7 +12,7 @@ public sealed class AgentMcpTools public AgentMcpTools(AgentFileService agents) => _agents = agents; - [McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")] + [McpServerTool, Description("List available agent definition files (name, description, path) to pick a value for a task's or list's agentPath override.")] public async Task> ListAgents(CancellationToken cancellationToken) => await _agents.ScanAsync(cancellationToken); } diff --git a/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs b/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs index b7c8c72f..82f3ab90 100644 --- a/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs +++ b/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs @@ -19,7 +19,7 @@ public sealed class AppSettingsMcpTools public AppSettingsMcpTools(IDbContextFactory dbFactory) => _dbFactory = dbFactory; - [McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, max parallel execution slots, worktree strategy). Read-only.")] + [McpServerTool, Description("Read the worker's global defaults (model, max turns, permission mode, max parallel execution slots, worktree strategy) that apply when a task/list doesn't override them. Read-only.")] public async Task GetAppSettings(CancellationToken cancellationToken) { using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); diff --git a/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs index b387f09a..dd1096c7 100644 --- a/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs +++ b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs @@ -33,17 +33,15 @@ public sealed class AttachmentMcpTools } [McpServerTool, Description( - "Attach a read-only reference file to a task. These files are handed to the agent at run time, " + - "making them useful to prepare context for a task that will run later (e.g. plans, scripts, specs). " + - "Pass textContent for plain-text files (plans, markdown, scripts). " + - "Pass base64Content only for binary files (images, archives). Exactly one of the two must be provided. " + - "Re-attaching a file with the same fileName overwrites the previous version. " + - "Refuses if the task is currently Running — cancel it first.")] + "Attach a read-only reference file to a task so the agent receives it at run time — use to prepare " + + "context (plans, scripts, specs) for a task that will run later. Exactly one of textContent/" + + "base64Content is required. Re-attaching the same fileName overwrites the previous version." + + McpToolDocs.NotWhileRunning)] public async Task AddTaskAttachment( string taskId, - string fileName, - string? textContent = null, - string? base64Content = null, + [Description("Name to store the attachment under; reusing an existing name overwrites it.")] string fileName, + [Description("Plain-text content (plans, markdown, scripts). Provide this or base64Content, not both.")] string? textContent = null, + [Description("Base64-encoded content for binary files (images, archives). Provide this or textContent, not both.")] string? base64Content = null, CancellationToken ct = default) { var task = await _tasks.GetByIdAsync(taskId, ct) @@ -94,7 +92,8 @@ public sealed class AttachmentMcpTools return new AttachmentDto(fileName, byteSize, existing?.CreatedAt ?? DateTime.UtcNow); } - [McpServerTool, Description("List all attachments on a task (fileName, byteSize, createdAt).")] + [McpServerTool, Description( + "List all attachments on a task — use to check what reference files are already attached before adding more.")] public async Task> ListTaskAttachments( string taskId, CancellationToken ct = default) { @@ -103,8 +102,8 @@ public sealed class AttachmentMcpTools } [McpServerTool, Description( - "Remove a single attachment from a task. Deletes both the file on disk and the database record. " + - "Refuses if the task is currently Running — cancel it first. Returns { removed: true, taskId, fileName } on success.")] + "Remove a single attachment from a task, deleting both the file on disk and its database record." + + McpToolDocs.NotWhileRunning)] public async Task RemoveTaskAttachment( string taskId, string fileName, CancellationToken ct = default) { diff --git a/src/ClaudeDo.Worker/External/BatchMcpTools.cs b/src/ClaudeDo.Worker/External/BatchMcpTools.cs index 7fed1f9f..14ccb54e 100644 --- a/src/ClaudeDo.Worker/External/BatchMcpTools.cs +++ b/src/ClaudeDo.Worker/External/BatchMcpTools.cs @@ -3,8 +3,14 @@ using ModelContextProtocol.Server; namespace ClaudeDo.Worker.External; -public sealed record BatchAddTaskInput(string Title, string? Description = null, string? Model = null); -public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null); +public sealed record BatchAddTaskInput( + string Title, + [property: Description("Task description/instructions for the agent.")] string? Description = null, + [property: Description("Model override: haiku|sonnet|opus. Blank inherits the list/global default.")] string? Model = null); +public sealed record BatchSetMyDayInput( + string TaskId, + [property: Description("true to add the task to My Day, false to remove it.")] bool IsMyDay, + [property: Description("Position within My Day; omit to append at the end.")] int? SortOrder = null); // task is populated when found and includeDescription=false (the default, lean reference); // taskFull is populated when found and includeDescription=true (full task incl. @@ -34,14 +40,14 @@ public sealed class BatchMcpTools public BatchMcpTools(ExternalMcpService svc) => _svc = svc; [McpServerTool, Description( - "Fetch a snapshot of many tasks in one call (overview / polling a fan-out). " + - "Returns one result per id: { id, found, task, taskFull, error }. " + - "includeDescription=false (default): found tasks come back in `task` (lean reference, no " + - "Description/Result). includeDescription=true: found tasks come back in `taskFull` (incl. " + - "Description/Result) instead. A missing id is found=false (not an error; task and taskFull both null); " + - "error is only set for an unexpected failure. Max 100 ids.")] + "Fetch a snapshot of many tasks in one call — use for an overview or polling a fan-out instead of " + + "calling get_task per id. A missing id comes back as found=false, not an error; error is only set " + + "for an unexpected failure." + McpToolDocs.MaxBatch)] public async Task> BatchGetTasks( - string[] taskIds, bool includeDescription = false, CancellationToken cancellationToken = default) + string[] taskIds, + [Description("If true, return the full task (incl. Description/Result) in `taskFull`; if false " + + "(default), return a lean reference in `task`.")] bool includeDescription = false, + CancellationToken cancellationToken = default) { EnsureWithinCap(taskIds, nameof(taskIds)); @@ -75,19 +81,15 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Create many tasks in one list at once. Each item: { title, description?, model? } " + - "(model: haiku|sonnet|opus, blank = inherit list/global default). " + - "queueImmediately enqueues every created task. " + - "Returns one result per item: { index, title, ok, task, possibleDuplicates, error }; task is " + - "a lean reference (id, listId, title, status, sortOrder, isMyDay), not the description you just " + - "sent. Each item is always created — possibleDuplicates is a non-blocking heads-up (up to 3 open " + - "tasks in the same list with a strongly overlapping title, id/title/status only); check it and " + - "mention any hit to the caller, but do not treat it as an error. Max 100 items.")] + "Create many tasks in one list at once — use instead of repeated add_task calls when seeding a list. " + + "Every item is still created even if it looks like a duplicate; possibleDuplicates is a non-blocking " + + "heads-up (up to 3 similar open tasks in the list) worth mentioning to the caller, not an error." + + McpToolDocs.LeanTaskRef + McpToolDocs.MaxBatch)] public async Task> BatchAddTasks( string listId, BatchAddTaskInput[] tasks, string? createdBy = null, - bool queueImmediately = false, + [Description("If true, enqueue every created task immediately instead of leaving it Idle.")] bool queueImmediately = false, CancellationToken cancellationToken = default) { EnsureWithinCap(tasks, nameof(tasks)); @@ -113,11 +115,13 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Set the status of many tasks at once. status is 'Idle', 'Queued', 'Cancelled' or 'Done' only — " + - "same rule as update_task_status ('Done' is refused per-item for a task with an active worktree). " + - "Returns one result per id: { taskId, ok, error }. Max 100 ids.")] + "Set the status of many tasks at once — use for bulk queue/cancel/done actions instead of calling " + + "update_task_status per task. 'Done' is refused per-item for a task with an active worktree." + + McpToolDocs.MaxBatch)] public async Task> BatchUpdateTaskStatus( - string[] taskIds, string status, CancellationToken cancellationToken) + string[] taskIds, + [Description("One of 'Idle', 'Queued', 'Cancelled', or 'Done'.")] string status, + CancellationToken cancellationToken) { EnsureWithinCap(taskIds, nameof(taskIds)); return await RunPerTaskAsync(taskIds, @@ -125,9 +129,8 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Cancel many running tasks at once. Returns one result per id: " + - "{ taskId, ok, cancelled, error }. cancelled=false means the task was not running. " + - "Max 100 ids.")] + "Cancel many running tasks at once — use to bulk-stop tasks instead of calling cancel_task per id. " + + "ok=true with cancelled=false just means the task wasn't running." + McpToolDocs.MaxBatch)] public async Task> BatchCancelTasks( string[] taskIds, CancellationToken cancellationToken) { @@ -151,8 +154,8 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Delete many tasks at once. A Running task is refused (cancel it first) and reported " + - "as ok=false with its error. Returns one result per id: { taskId, ok, error }. Max 100 ids.")] + "Delete many tasks at once — use for bulk cleanup instead of calling delete_task per id." + + McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch)] public async Task> BatchDeleteTasks( string[] taskIds, CancellationToken cancellationToken) { @@ -162,9 +165,9 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Daily prep: set/clear MyDay for many tasks at once. Each item: { taskId, isMyDay, sortOrder? }. " + - "Still cap-guarded — items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " + - "(ok=false) without blocking the rest. Returns one result per item: { taskId, ok, error }. Max 100 items.")] + "Set or clear MyDay (daily prep) for many tasks at once — use instead of calling set_my_day per task. " + + "Still cap-guarded: items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " + + "(ok=false) without blocking the rest." + McpToolDocs.MaxBatch)] public async Task> BatchSetMyDay( BatchSetMyDayInput[] items, CancellationToken cancellationToken) { @@ -188,12 +191,13 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Remove the worktrees of many tasks at once (directory + git branch). " + - "force=false refuses a dirty or Running worktree (reported ok=false); force=true removes " + - "even a dirty worktree (uncommitted changes lost), still refusing Running tasks. " + - "Returns one result per id: { taskId, ok, removed, branchDeleted, error }. Max 100 ids.")] + "Remove the worktrees (directory + git branch) of many tasks at once — use for bulk cleanup instead " + + "of calling cleanup_task_worktree per id." + McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch)] public async Task> BatchCleanupTaskWorktrees( - string[] taskIds, bool force = false, CancellationToken cancellationToken = default) + string[] taskIds, + [Description("If true, also remove a dirty worktree, losing uncommitted changes; a Running task " + + "is still refused either way.")] bool force = false, + CancellationToken cancellationToken = default) { EnsureWithinCap(taskIds, nameof(taskIds)); diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index 5556d26c..383a13a1 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -46,7 +46,7 @@ public sealed class ConfigMcpTools _dbFactory = dbFactory; } - [McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns { found: false, config: null } if no config is set.")] + [McpServerTool, Description("Read a list's default run config — the fallback used by tasks in this list that don't set their own overrides. Returns { found: false, config: null } if none is set.")] public async Task GetListConfig(string listId, CancellationToken cancellationToken) { var cfg = await _lists.GetConfigAsync(listId, cancellationToken); @@ -56,9 +56,8 @@ public sealed class ConfigMcpTools } [McpServerTool, Description( - "Set a list's default model/system prompt/agent path/max turns. Passing all four as null clears the list " + - "config. Returns { ok, listId, config } — config is null when the config was cleared, otherwise it echoes " + - "the fields that were set (a field is null there if it was individually left unset/cleared).")] + "Set a list's default model/system prompt/agent path/max turns — the fallback for tasks in this list " + + "that don't override them. Passing all four as null clears the list config instead of setting one.")] public async Task SetListConfig( string listId, string? model = null, string? systemPrompt = null, string? agentPath = null, int? maxTurns = null, CancellationToken cancellationToken = default) @@ -90,9 +89,8 @@ public sealed class ConfigMcpTools } [McpServerTool, Description( - "Set per-task config overrides (model/system prompt/agent path/max turns). Pass null for any field to " + - "clear that override. Returns { ok, taskId, config } — config echoes the resulting overrides (a field is " + - "null there if it was cleared or never set).")] + "Set per-task overrides for model/system prompt/agent path/max turns; these take precedence over the " + + "list's default config for this one task. Pass null for any field to clear that override.")] public async Task SetTaskConfig( string taskId, string? model = null, string? systemPrompt = null, string? agentPath = null, int? maxTurns = null, CancellationToken cancellationToken = default) @@ -109,7 +107,7 @@ public sealed class ConfigMcpTools return new SetTaskConfigResult(true, taskId, new TaskConfigDto(m, sp, ap, maxTurns)); } - [McpServerTool, Description("Get per-task config overrides (model/system prompt/agent path/max turns). Returns { found: false, config: null } if no override is set on this task.")] + [McpServerTool, Description("Read this task's per-task overrides (model/system prompt/agent path/max turns), which take precedence over the list's default config. Returns { found: false, config: null } if none is set.")] public async Task GetTaskConfig(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -120,11 +118,11 @@ public sealed class ConfigMcpTools } [McpServerTool, Description( - "Get the config a task will ACTUALLY run with — model, max turns, effort, permission mode, agent path, " + - "whether a system prompt is set, and skill names — with each field's source (task/list/preset/global). " + - "Uses the exact same resolution TaskRunner runs with, so this never drifts from get_app_settings/" + - "get_task_config's raw, possibly-unused values. maxTurns also reports the raw requested value and " + - "whether it was clamped to the global ceiling. Read-only, no side effects.")] + "Report the config a task will ACTUALLY run with — model, max turns, effort, permission mode, agent " + + "path, whether a system prompt is set, and skill names — each tagged with its source " + + "(task/list/preset/global). Use this over get_task_config/get_app_settings when you need resolved " + + "values, not raw overrides. maxTurns also reports the raw requested value and whether it was clamped " + + "to the global ceiling.")] public async Task GetEffectiveRunConfig(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 00637b00..23fc6319 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -26,7 +26,6 @@ public sealed record CancelTaskResult(bool Cancelled, string Id); // review range (worktree ahead, or HandlerBaseCommit..HandlerHeadCommit for a worktree-less // child) contributed nothing, so a reviewer sees them before approving instead of after. public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList? EmptyChildren = null); -public sealed record StatusValueDto(string Status, string Meaning); public sealed record RunTaskNowResult(bool Started, string TaskId); public sealed record TaskDto( @@ -161,7 +160,8 @@ public sealed class ExternalMcpService _planningMerge = planningMerge; } - [McpServerTool, Description("List all task lists available in ClaudeDo.")] + [McpServerTool, Description( + "List all task lists available in ClaudeDo. Start here — every task tool needs a listId from this call.")] public async Task> ListTaskLists(CancellationToken cancellationToken) { var lists = await _lists.GetAllAsync(cancellationToken); @@ -169,17 +169,17 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " + - "Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled. " + - "includeDescription=false (default): returns lean task references in `tasks` (no Description/Result) — " + - "use this unless you actually need the description text, since a list of verbosely-described tasks can " + - "otherwise blow past the response size limit. " + - "includeDescription=true: returns full tasks (incl. Description/Result) in `tasksFull` instead; `tasks` is " + - "null in that case.")] + "List the tasks in one list — the usual way to find a taskId. Optionally filter by creator and/or status.")] public async Task ListTasks( string listId, + [Description("Only return tasks with this CreatedBy value.")] string? createdBy = null, + [Description("Only return tasks in this status: Idle, Queued, Running, WaitingForReview, " + + "WaitingForChildren, Done, Failed or Cancelled.")] string? status = null, + [Description("false (default): lean references in `tasks`, no Description/Result — keep this unless you " + + "need the description text, since verbosely-described tasks can blow past the response size " + + "limit. true: full tasks in `tasksFull` instead (`tasks` is then null).")] bool includeDescription = false, CancellationToken cancellationToken = default) { @@ -206,10 +206,12 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Get a single task by id, including its current status and result. " + - "Status lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " + - "A successful run lands in WaitingForReview; use review_task to approve, reject, or cancel. " + - "Done/Failed/Cancelled tasks can be reset to Idle for re-execution.")] + "Get a single task by id, including its current status and result — the canonical reference for what a " + + "status means. Lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " + + "A successful run lands in WaitingForReview; use review_task to approve, reject or cancel it. " + + "Done/Failed/Cancelled tasks can be reset to Idle for re-execution. A Queued task with a blocker waits " + + "for its predecessor before the picker will claim it, and WaitingForChildren is a parent whose own work " + + "is done but whose children are still running.")] public async Task GetTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -227,21 +229,19 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. " + - "Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " + - "'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " + - "Leave model null to inherit the list/global default. " + - "Returns { task, possibleDuplicates }: task is a lean reference (id, listId, title, status, " + - "sortOrder, isMyDay) — not the description you just sent. The task is always created — " + - "possibleDuplicates is a non-blocking heads-up (up to 3 open tasks in the same list with a " + - "strongly overlapping title, id/title/status only); check it and mention any hit to the caller, " + - "but do not treat it as an error.")] + "Create a new task in the given list. The task is always created — possibleDuplicates is a non-blocking " + + "heads-up (up to 3 open tasks in the same list with a strongly overlapping title); check it and mention " + + "any hit to the caller, but do not treat it as an error." + McpToolDocs.LeanTaskRef)] public async Task AddTask( string listId, string title, string? description = null, string? createdBy = null, + [Description("true: enqueue the task for agent execution right away.")] bool queueImmediately = false, + [Description("Cheapest model that can do the task well: 'haiku' for trivial/mechanical work, 'sonnet' " + + "for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " + + "list/global default (normally sonnet).")] string? model = null, CancellationToken cancellationToken = default) { @@ -354,9 +354,8 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. " + - "Refuses if the task is currently Running. Returns a lean task reference (id, listId, title, status, " + - "sortOrder, isMyDay) — not the description you just sent.")] + "Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged." + + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)] public async Task UpdateTask( string taskId, string? title = null, @@ -380,12 +379,12 @@ public sealed class ExternalMcpService } [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. " + - "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")] + "Append a subtask (step) to a task. Subtasks are surfaced to the agent at run time and shown in the " + + "task's Steps list." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)] public async Task AddSubtask( string taskId, string title, + [Description("Position among the existing steps; defaults to the end.")] int? orderNum = null, CancellationToken cancellationToken = default) { @@ -419,16 +418,14 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Update a task's status. Only 'Idle', 'Queued', 'Cancelled' and 'Done' are permitted externally — " + - "use run_task_now for execution control, and review_task to act on a WaitingForReview task. " + - "Settable: Idle (reset to editable), Queued (enqueue for execution), " + - "Cancelled (retire the task without deleting it; it can be reset to Idle later), " + - "Done (mark complete; refused if the task has an active worktree — use review_task to approve " + - "and merge that worktree instead). " + - "Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " + - "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")] + "Move a task between the statuses a caller may set directly. Use run_task_now for execution control and " + + "review_task to act on a WaitingForReview task — neither is reachable from here." + McpToolDocs.LeanTaskRef)] public async Task UpdateTaskStatus( string taskId, + [Description("'Idle' (reset to editable), 'Queued' (enqueue for execution), 'Cancelled' (retire without " + + "deleting; can be reset to Idle later) or 'Done' (mark complete; refused if the task has an " + + "active worktree — use review_task to approve and merge that worktree instead). No other " + + "value is settable externally.")] string status, CancellationToken cancellationToken) { @@ -482,27 +479,29 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Review a task that is WaitingForReview. " + - "decision='approve' → review+merge, exactly like the UI's Approve: a childless task merges its worktree into " + - "targetBranch (default: the repo's current branch) then goes Done; a task with children drives the unit merge " + - "(parent worktree if active + each Done child in order); a task without an active worktree approves straight to Done. " + - "mergeStatus 'conflict' means the merge stopped on conflicts (files listed) — by default the merge is cleanly " + - "aborted and you resolve in the ClaudeDo UI; pass leaveConflictsInTree=true to instead leave the conflict " + - "markers in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " + - "or abort_merge to cancel. " + - "decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " + - "decision='reject_park' → Idle for manual editing (feedback ignored). " + - "decision='cancel' → Cancelled. " + - "Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued). " + - "The result's task field is a lean reference (id, listId, title, status, sortOrder, isMyDay), not the task's description. " + - "emptyChildren (parent approve only) lists the Done children about to be unit-merged whose own review range " + - "contributed nothing (e.g. a child that reported CLAUDEDO_BLOCKED and committed no code) — check it before " + - "trusting that every child actually delivered something.")] + "Act on a task that is WaitingForReview — the only way to approve, reject or retire a reviewed run. " + + "'approve' is review+merge, exactly like the UI's Approve: a childless task merges its worktree into " + + "targetBranch then goes Done; a task with children drives the unit merge (parent worktree if active + each " + + "Done child in order); a task without an active worktree approves straight to Done. Fails if the task is " + + "not WaitingForReview (except 'cancel', which also works while Running/Queued). mergeStatus 'conflict' " + + "means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " + + "the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " + + "reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " + + "delivered something." + McpToolDocs.LeanTaskRef)] public async Task ReviewTask( string taskId, + [Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")] string decision, + [Description("Rejection comment. Required for 'reject_rerun', where the task goes Queued and re-runs with " + + "this text as the next turn of the agent's resumed session; ignored for 'reject_park', which " + + "just returns the task to Idle for manual editing.")] string? feedback = null, + [Description("Branch an approve merges into; defaults to the repo's current branch.")] string? targetBranch = null, + [Description("What an approve does when the merge hits conflicts. false (default): abort cleanly, leaving " + + "no half-merged state, and you resolve in the ClaudeDo UI. true: leave the conflict markers " + + "in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " + + "or abort_merge to cancel.")] bool leaveConflictsInTree = false, CancellationToken cancellationToken = default) { @@ -634,7 +633,10 @@ public sealed class ExternalMcpService } } - [McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue). Returns { started: true, taskId } on success.")] + [McpServerTool, Description( + "Run a task immediately in the override execution slot, bypassing the agent queue. That slot is single-" + + "occupancy and shared with continue_task — throws \"Override slot busy\" if something else holds it; " + + "enqueue via update_task_status instead of retrying in a loop.")] public async Task RunTaskNow(string taskId, CancellationToken cancellationToken) { try @@ -653,7 +655,8 @@ public sealed class ExternalMcpService return new RunTaskNowResult(true, taskId); } - [McpServerTool, Description("Cancel a running task. Returns { cancelled: true, id } if the task was running and cancellation was requested; cancelled is false if the task was not running.")] + [McpServerTool, Description( + "Cancel a running task, killing its agent process. cancelled=false means the task was not running.")] public async Task CancelTask(string taskId, CancellationToken cancellationToken) { var cancelled = _queue.CancelTask(taskId); @@ -661,7 +664,9 @@ public sealed class ExternalMcpService return new CancelTaskResult(cancelled, taskId); } - [McpServerTool, Description("Delete a task. Returns { deleted: true, id } on success. Throws if the task is not found or is currently Running — cancel it first.")] + [McpServerTool, Description( + "Delete a task permanently. Prefer update_task_status 'Cancelled' to retire a task you may want back." + + McpToolDocs.NotWhileRunning)] public async Task DeleteTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -676,31 +681,12 @@ public sealed class ExternalMcpService return new DeleteTaskResult(true, taskId); } - // ── Status reference ───────────────────────────────────────────────────── - - [McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")] - public Task> GetTaskStatusValues() => - Task.FromResult>([ - new("Idle", "Not yet queued; task is editable and will not run until enqueued."), - new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."), - new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."), - new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."), - new("WaitingForChildren", "Planning parent whose child tasks are still running. The parent resumes once all children reach a terminal state."), - new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."), - new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."), - new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."), - ]); - // ── Worktree / git tools ────────────────────────────────────────────────── [McpServerTool, Description( - "Get git worktree details for a task: path, branch, headCommit (current HEAD SHA), " + - "baseCommit (SHA where the branch was created), ahead (commits on branch since base), " + - "behind (commits on main not yet on this branch; 0 if 'main' ref is unreachable), " + - "isDirty (has uncommitted changes in the worktree directory), " + - "mergeCommit (SHA of the merge commit this worktree's branch produced on the target branch, " + - "if it has been merged and that succeeded after this field was introduced; null otherwise — " + - "required by revert_merge). " + + "Get a task's git worktree state — path, branch, base/head commit, ahead/behind counts, isDirty, and the " + + "mergeCommit its branch produced once merged. behind is 0 when the 'main' ref is unreachable, so do not " + + "read 0 as \"up to date\" without checking. A null mergeCommit means revert_merge cannot act on this task. " + "Throws if the task or its worktree does not exist.")] public async Task GetTaskWorktree(string taskId, CancellationToken cancellationToken) { @@ -718,16 +704,17 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Get the diff for a task's worktree relative to its base commit. For a worktree-less " + - "list-handler host task (Mission Control's \"Let Claude handle it\"), returns the fixed " + - "HandlerBaseCommit..HandlerHeadCommit range over the list's working dir instead. " + - "stat=false (default): returns the full unified diff, capped at 200 KB (truncated=true when larger). " + - "stat=true: returns a --stat summary (changed files with insertion/deletion counts). " + - "files always lists the changed file paths regardless of stat mode. " + - "totalBytes is the uncapped diff size (useful when truncated=true). " + - "Throws if the task has no worktree/review range, or the relevant directory is missing from disk.")] + "Read what a task actually changed — the diff of its worktree against its base commit (for a worktree-less " + + "list-handler host task, the fixed HandlerBaseCommit..HandlerHeadCommit range over the list's working dir " + + "instead). files lists the changed paths in either mode; truncated=true means the diff was capped and " + + "totalBytes holds its real size. Throws if the task has no worktree/review range, or the relevant " + + "directory is missing from disk.")] public async Task GetTaskDiff( - string taskId, bool stat = false, CancellationToken cancellationToken = default) + string taskId, + [Description("false (default): the full unified diff, capped at 200 KB. true: a --stat summary with " + + "per-file insertion/deletion counts — start here when the diff may be large.")] + bool stat = false, + CancellationToken cancellationToken = default) { var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken); @@ -782,21 +769,21 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Merge a task's worktree branch into targetBranch (default: main). " + - "noFf=true (default): always creates a merge commit (--no-ff). " + - "dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " + - "allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " + - "On success: merged=true, mergeCommit contains the new merge commit SHA. " + - "On conflict: by default the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files. " + - "leaveConflictsInTree=true: on conflict the merge is NOT aborted — conflict markers are left in the working " + - "tree at repoPath (conflictsInTree=true in the result) so you can resolve them there and call continue_merge, " + - "or abort_merge to cancel.")] + "Merge a Done task's worktree branch into targetBranch. For a task still in WaitingForReview prefer " + + "review_task, which merges as part of approving. merged=true carries the new mergeCommit SHA; on conflict " + + "merged=false and conflicts lists the affected files.")] public async Task MergeTask( string taskId, string targetBranch = "main", + [Description("true (default): always create a merge commit (--no-ff).")] bool noFf = true, + [Description("true: validate preconditions only and do not merge — merged=false then means \"not attempted\".")] bool dryRun = false, + [Description("true: also allow merging a task in WaitingForReview; false (default) allows Done only.")] bool allowWaitingForReview = false, + [Description("What to do on conflict. false (default): abort cleanly, leaving no half-merged state. true: " + + "leave the conflict markers in the working tree at repoPath (conflictsInTree=true) so you can " + + "resolve them there and call continue_merge, or abort_merge to cancel.")] bool leaveConflictsInTree = false, CancellationToken cancellationToken = default) { @@ -848,11 +835,9 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Finish an in-progress conflicted merge after the conflict markers in the working tree (repoPath from " + - "merge_task/review_task) have been resolved. Handles both a single task's merge and a parent/children unit " + - "merge — pass the PARENT task id to continue a unit merge. On success merged=true and the task reaches its " + - "post-merge status (Done when approving). If conflict markers are still present, merged=false and conflicts " + - "lists the affected files — resolve them and call continue_merge again. " + + "Finish an in-progress conflicted merge once you have resolved the conflict markers in the working tree " + + "(repoPath from merge_task/review_task). Pass the PARENT task id to continue a parent/children unit merge. " + + "merged=false with conflicts listed means markers are still present — resolve them and call again. " + "Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead.")] public async Task ContinueMerge(string taskId, CancellationToken cancellationToken) { @@ -920,10 +905,8 @@ public sealed class ExternalMcpService [McpServerTool, Description( "Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " + - "Handles both a single task's merge and a parent/children unit merge — pass the PARENT task id to abort a " + - "unit merge. The task keeps its pre-merge status (e.g. WaitingForReview). " + - "Throws if there is no in-progress merge for the task. " + - "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")] + "Pass the PARENT task id to abort a parent/children unit merge. The task keeps its pre-merge status " + + "(e.g. WaitingForReview). Throws if there is no in-progress merge for the task." + McpToolDocs.LeanTaskRef)] public async Task AbortMerge(string taskId, CancellationToken cancellationToken) { _ = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -945,21 +928,17 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Non-destructive merge preview for a task's worktree branch against targetBranch (default: the repo's " + - "current branch), via `git merge-tree --write-tree` — does NOT touch the working tree, index, or HEAD. " + - "status: 'clean' (mergeable; changedFileCount is the size of that merge) or 'conflict' (conflictFiles " + - "lists the paths git would stop on). behind = commits on targetBranch not yet on this task's branch, so " + - "you can spot a stale branch even when the preview itself is clean. " + - "IMPORTANT: a clean preview says nothing about whether the merged result compiles or passes tests — git " + - "can merge two changes cleanly (e.g. one file deletes a symbol another file still references) and still " + - "break the build. " + - "isEmpty=true means the task's review range contributed nothing (no commits ahead of base, or — for a " + - "worktree-less list-handler host task — HandlerBaseCommit == HandlerHeadCommit); do not mistake a small " + - "changedFileCount for an empty one, check isEmpty instead. " + - "Throws a clear error if the task has neither an active worktree nor a handler commit range, or the " + - "list's working directory is missing from disk.")] + "Check whether a task would merge cleanly before touching anything — `git merge-tree --write-tree`, so the " + + "working tree, index and HEAD are untouched. status is 'clean' or 'conflict' (conflictFiles lists where git " + + "would stop); behind counts commits on targetBranch not yet on this branch, which flags a stale branch even " + + "when the preview is clean. IMPORTANT: a clean preview says nothing about whether the result compiles or " + + "passes tests — git can merge two changes cleanly (one file deleting a symbol another still references) and " + + "still break the build. isEmpty=true means the task's review range contributed nothing; check that flag " + + "rather than reading a small changedFileCount as empty. Throws if the task has neither an active worktree " + + "nor a handler commit range, or the list's working directory is missing from disk.")] public async Task PreviewMerge( string taskId, + [Description("Branch to preview against; defaults to the repo's current branch.")] string? targetBranch = null, CancellationToken cancellationToken = default) { @@ -968,18 +947,16 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Merge preview plus file-overlap check across several tasks at once, all previewed against the same " + - "targetBranch (default: the repo's current branch). For each taskId returns the same fields as " + - "preview_merge (status/conflictFiles/changedFileCount/behind; error is set instead if that task could not " + - "be previewed, and it is then excluded from the overlap computation). overlaps lists, for each file " + - "touched by MORE THAN ONE of the given tasks (via each task's own diff, not the merge preview itself), " + - "which tasks touch it — passing a single taskId always yields an empty overlaps list. " + - "IMPORTANT: file-name overlap is a HINT, not a guarantee of a real collision, and its absence is not a " + - "guarantee of safety — two tasks touching different files entirely (e.g. one deletes a symbol, another " + - "still references it elsewhere) can still collide, and this tool will not flag that case. " + - "isEmpty=true (per entry) means that task's review range contributed nothing — see preview_merge.")] + "Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " + + "check between them. Per entry you get preview_merge's fields, or error instead when that task could not " + + "be previewed (it is then left out of the overlap computation). overlaps names, for each file touched by " + + "MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no overlaps. " + + "IMPORTANT: overlap is a HINT and its absence is not safety — two tasks touching entirely different files " + + "(one deleting a symbol, another still referencing it) can still collide unflagged, and as with " + + "preview_merge a clean result does not mean the merge builds.")] public async Task PreviewMergeSet( IReadOnlyList taskIds, + [Description("Branch to preview every task against; defaults to the repo's current branch.")] string? targetBranch = null, CancellationToken cancellationToken = default) { @@ -1076,18 +1053,17 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Revert a previously merged task's merge commit on targetBranch (default: main), via `git revert -m 1` — " + - "a new commit, never a reset/rewrite (the target working directory is shared with other sessions). " + - "Requires the task to be Done with a Merged worktree that has a recorded merge commit; tasks merged " + - "before this feature existed have no recorded commit and are refused rather than guessed via git log. " + - "On success: reverted=true, revertCommit is the new commit's SHA, and the task returns to " + - "WaitingForReview so it can be reconsidered. " + - "On a conflicting revert: reverted=false, the revert is aborted immediately (no half-resolved state " + - "left in the tree) and conflicts lists the files that would have conflicted. " + - "Throws if there is no recorded merge commit, the repo is mid-merge/mid-revert, or the target working " + - "tree has uncommitted changes from another session.")] + "Undo a merged task by reverting its merge commit — `git revert -m 1`, always a new commit and never a " + + "reset/rewrite, since the target working directory is shared with other sessions. Requires the task to be " + + "Done with a Merged worktree that has a recorded merge commit (check get_task_worktree's mergeCommit " + + "first). On success the task returns to WaitingForReview so it can be reconsidered. On conflict the revert " + + "is aborted immediately and conflicts lists the files. Throws if there is no recorded merge commit, the " + + "repo is mid-merge/mid-revert, or the target working tree has uncommitted changes from another session.")] public async Task RevertMerge( - string taskId, string targetBranch = "main", CancellationToken cancellationToken = default) + string taskId, + [Description("Branch carrying the merge commit; defaults to main.")] + string targetBranch = "main", + CancellationToken cancellationToken = default) { var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken); @@ -1104,10 +1080,8 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "List all ClaudeDo-tracked worktrees. " + - "Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " + - "isDirty (has uncommitted changes), mergedIntoMain (worktree state is Merged). " + - "Only worktrees recorded in the ClaudeDo database are returned.")] + "Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " + + "the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk.")] public async Task> ListWorktrees(CancellationToken cancellationToken) { var rows = await _maintenance.GetOverviewAsync(null, cancellationToken); @@ -1125,12 +1099,14 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Remove a task's worktree directory and delete its git branch. " + - "force=false (default): refuses if the worktree has uncommitted changes or the task is Running. " + - "force=true: removes even a dirty worktree (uncommitted changes are lost); task must not be Running. " + - "Returns removed=true on success; branchDeleted reflects whether the branch was also removed.")] + "Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " + + "went too." + McpToolDocs.NotWhileRunning)] public async Task CleanupTaskWorktree( - string taskId, bool force = false, CancellationToken cancellationToken = default) + string taskId, + [Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " + + "those changes.")] + bool force = false, + CancellationToken cancellationToken = default) { using var ctx = _dbFactory.CreateDbContext(); var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken) @@ -1155,10 +1131,9 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Send a follow-up prompt to an existing Claude session (multi-turn continuation). " + - "The agent resumes using --resume with the session ID from the task's last run. " + - "Runs in the override execution slot; throws if the slot is busy — try again later. " + - "Returns a status string from the execution slot.")] + "Send a follow-up prompt to a task's existing Claude session instead of starting a fresh run — the agent " + + "resumes via --resume with the session id from the task's last run, so its prior context is kept. Uses the " + + "same single-occupancy override slot as run_task_now and throws \"Override slot busy\" when that is taken.")] public async Task ContinueTask( string taskId, string followUpPrompt, @@ -1187,10 +1162,10 @@ public sealed class ExternalMcpService // ── Daily prep ─────────────────────────────────────────────────────────── [McpServerTool, Description( - "Daily prep: returns the open tasks eligible for today's MyDay selection. " + - "candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " + - "currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " + - "maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")] + "Daily prep: the open tasks eligible for today's MyDay selection. candidates are Idle, unblocked, " + + "non-manual and in a git repo not excluded from the weekly report; currentMyDay are Idle tasks already " + + "flagged and count toward maxTasks, the hard cap on open MyDay tasks. Add your picks with set_my_day and " + + "never exceed maxTasks.")] public async Task GetDailyPrepCandidates(CancellationToken cancellationToken) { await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); @@ -1225,14 +1200,13 @@ public sealed class ExternalMcpService } [McpServerTool, Description( - "Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " + - "(use consecutive sortOrder values to keep related tasks together). " + - "Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " + - "clearing (isMyDay=false) is always allowed. " + - "Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")] + "Daily prep: set or clear a task's MyDay flag. Setting it is rejected once the MyDay cap " + + "(DailyPrepMaxTasks open MyDay tasks) would be exceeded; clearing is always allowed." + + McpToolDocs.LeanTaskRef)] public async Task SetMyDay( string taskId, bool isMyDay, + [Description("Position in the MyDay list; use consecutive values to keep related tasks together.")] int? sortOrder = null, CancellationToken cancellationToken = default) { diff --git a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs index 6866d7c4..b44d05b7 100644 --- a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs +++ b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs @@ -20,13 +20,15 @@ public sealed class HandoffMcpTools } [McpServerTool, Description( - "End of Phase 2 for the list handler (\"Let Claude handle it\"): hand this run off to a fresh " + - "ConPTY session that carries out Phases 3-5, without dragging along this session's dedupe/rewrite " + - "context. taskId is this session's own handler task id; survivingTaskIds are the tasks that made " + - "it past dedupe, in the order to run them. Reuses the SAME handler task -- no new task is created, " + - "and HandlerBaseCommit is untouched. The current tile stays open; end your own turn after calling this.")] + "Call at the end of Phase 2 of the list handler (\"Let Claude handle it\") to hand this run off " + + "to a fresh ConPTY session that carries out Phases 3-5, without dragging along this session's " + + "dedupe/rewrite context. Reuses the SAME handler task -- no new task is created, and " + + "HandlerBaseCommit is untouched. The current tile stays open; you must end your own turn " + + "immediately after calling this.")] public async Task HandoffListHandler( - string taskId, IReadOnlyList survivingTaskIds, CancellationToken cancellationToken) + [Description("This session's own handler task id.")] string taskId, + [Description("The tasks that made it past dedupe, in the order to run them.")] IReadOnlyList survivingTaskIds, + CancellationToken cancellationToken) { if (survivingTaskIds.Count == 0) throw new InvalidOperationException("survivingTaskIds must contain at least one task id."); diff --git a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs index aa1ecb7a..3980f51f 100644 --- a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs +++ b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs @@ -20,7 +20,7 @@ public sealed class LifecycleMcpTools _reset = reset; } - [McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted. Returns { reset: true, taskId } on success.")] + [McpServerTool, Description("Reset a failed task back to Idle so it can be run again, discarding its now-stale worktree. Only tasks with Status=Failed are accepted; other statuses throw.")] public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) diff --git a/src/ClaudeDo.Worker/External/ListMcpTools.cs b/src/ClaudeDo.Worker/External/ListMcpTools.cs index 72fe5f2f..5ac26da7 100644 --- a/src/ClaudeDo.Worker/External/ListMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ListMcpTools.cs @@ -21,9 +21,14 @@ public sealed class ListMcpTools _broadcaster = broadcaster; } - [McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")] + [McpServerTool, Description("Create a new task list — the top-level grouping tasks belong to, with its own working dir, commit type, and default run config.")] public async Task CreateList( - string name, string? workingDir = null, string? commitType = null, CancellationToken cancellationToken = default) + string name, + [Description("Absolute local path to an existing git repository this list's tasks will run against. Not validated here — the first task run fails if the path isn't an actual git repo. Omit to run this list's tasks in a throwaway sandbox with no worktree.")] + string? workingDir = null, + [Description("Conventional-commit-style type prefix for this list's task commits (e.g. 'feat', 'fix'). Defaults to 'chore'.")] + string? commitType = null, + CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(name)) throw new InvalidOperationException("name is required."); @@ -41,9 +46,14 @@ public sealed class ListMcpTools return ToDto(entity); } - [McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")] + [McpServerTool, Description("Rename a list, or change its working dir / default commit type without recreating it. Pass null for any field to leave it unchanged.")] public async Task UpdateList( - string listId, string? name = null, string? workingDir = null, string? commitType = null, + string listId, + string? name = null, + [Description("Absolute local path to an existing git repository this list's tasks will run against; not validated until the next task runs. Null leaves it unchanged; pass an empty string to clear it and switch this list to sandbox-only task runs.")] + string? workingDir = null, + [Description("New default commit type prefix for this list's task commits. Null leaves it unchanged.")] + string? commitType = null, CancellationToken cancellationToken = default) { var entity = await _lists.GetByIdAsync(listId, cancellationToken) @@ -62,7 +72,7 @@ public sealed class ListMcpTools return ToDto(entity); } - [McpServerTool, Description("Delete a list and its tasks. Irreversible. Returns { deleted: true, id } on success.")] + [McpServerTool, Description("Permanently delete a list and all its tasks — no undo. Only for removing the whole list, not a single task within it.")] public async Task DeleteList(string listId, CancellationToken cancellationToken) { _ = await _lists.GetByIdAsync(listId, cancellationToken) diff --git a/src/ClaudeDo.Worker/External/McpToolDocs.cs b/src/ClaudeDo.Worker/External/McpToolDocs.cs new file mode 100644 index 00000000..46b814af --- /dev/null +++ b/src/ClaudeDo.Worker/External/McpToolDocs.cs @@ -0,0 +1,28 @@ +namespace ClaudeDo.Worker.External; + +/// +/// Boilerplate clauses shared by several external MCP tool descriptions. Every tool description is +/// still emitted in full to the client — these constants only stop the wording from drifting apart +/// across ~50 attributes. +/// +/// Description style (keep new tools in line with it): +/// 1. First sentence says what the tool does AND when to reach for it — MCP clients rank tools by +/// this text, so the trigger must not be buried behind return-shape prose. +/// 2. Then only non-obvious preconditions and refusals. +/// 3. Document parameters with [Description] on the parameter, not in the tool description. +/// 4. Describe result fields only where the caller must branch on them (isEmpty, truncated, +/// conflicts, …). Everything else is visible in the first actual response. +/// 5. No design rationale or "since this feature was introduced" history. +/// Budget: ~400 chars for a simple tool, ~800 for the merge/review family. +/// +internal static class McpToolDocs +{ + /// Warns that the payload is the lean reference, not the task's description/result. + public const string LeanTaskRef = " Returns a lean task reference, not the task's description."; + + /// Batch-size cap shared by every BatchMcpTools entry point. + public const string MaxBatch = " Max 100 per call."; + + /// Mutations that refuse to touch a task while its agent is running. + public const string NotWhileRunning = " Refused while the task is Running — cancel it first."; +} diff --git a/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs b/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs index fc1e9763..d1d65ca4 100644 --- a/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs +++ b/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs @@ -28,15 +28,12 @@ public sealed class QueueStateMcpTools } [McpServerTool, Description( - "Read-only snapshot of the execution queue -- observe slot occupancy instead of inferring " + - "it from maxParallelExecutions. Result: { configuredSlots, effectiveSlots, activeSlots: " + - "[{ slot, taskId, startedAt }], waitingTaskIds }. configuredSlots is Settings -> " + - "MaxParallelExecutions; effectiveSlots is that value stepped down by the usage throttle " + - "(lower when the 5h/7d usage window is filling up) -- compare the two to see whether " + - "throttling is currently active. activeSlots lists every task presently holding an " + - "execution slot, with slot \"queue\" for a normal queue slot or \"override\" for the single " + - "run_task_now/continue_task slot. waitingTaskIds lists queued, unblocked, non-manual, due " + - "tasks in the order the queue would pick them next.")] + "Read-only snapshot of the execution queue -- call this to observe slot occupancy instead " + + "of inferring it from maxParallelExecutions. effectiveSlots is configuredSlots stepped down " + + "by the usage throttle (lower when the 5h/7d usage window fills up), so comparing the two " + + "shows whether throttling is currently active. Each active slot is \"queue\" (a normal " + + "queue slot) or \"override\" (the single run_task_now/continue_task slot). waitingTaskIds " + + "lists queued, unblocked, non-manual, due tasks in the order the queue would pick them next.")] public async Task GetQueueState(CancellationToken cancellationToken = default) { var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken); diff --git a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs index 06e4d799..d334fc7a 100644 --- a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs +++ b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs @@ -24,14 +24,17 @@ public sealed class RunHistoryMcpTools public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs; - [McpServerTool, Description("List all execution runs for a task (newest run metadata, tokens, turns, result, error).")] + [McpServerTool, Description( + "List all execution runs for a task — metadata, tokens, turns, result, and error per run — ordered " + + "oldest to newest by run number, so the last entry is the most recent. Use a run's id from here with " + + "get_run to fetch it individually.")] public async Task> ListRuns(string taskId, CancellationToken cancellationToken) { var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken); return runs.Select(ToDto).ToList(); } - [McpServerTool, Description("Get a single execution run by its run id.")] + [McpServerTool, Description("Get one execution run's full detail by its run id, obtained from list_runs.")] public async Task GetRun(string runId, CancellationToken cancellationToken) { var run = await _runs.GetByIdAsync(runId, cancellationToken) @@ -40,18 +43,16 @@ public sealed class RunHistoryMcpTools } [McpServerTool, Description( - "Fetch log entries from a task's latest run. " + - "Returns { available, entries, totalLines, truncated }. " + - "available=false means no log exists yet (task is queued or just started — not an error). " + - "entries are the individual lines (NDJSON messages) from Claude's streaming output. " + - "Default: returns the last 50 entries (tail=50). " + - "tail: override the number of trailing entries to return. " + - "offset+limit: return entries starting at position offset (0-based); overrides tail when provided. " + - "truncated=true when fewer entries are returned than totalLines.")] + "Fetch NDJSON log lines from a task's latest run — use this to check progress or debug a task without " + + "opening the log file. Defaults to the last 50 lines. available=false means no log exists yet (queued " + + "or just started — not an error); truncated=true when fewer entries are returned than totalLines.")] public async Task GetTaskLog( string taskId, + [Description("Number of trailing entries to return; ignored if offset or limit is set. Default 50.")] int? tail = null, + [Description("0-based entry index to start from; overrides tail when set. Combine with limit to page through the log.")] int? offset = null, + [Description("Max entries to return starting at offset. Omit to return everything from offset to the end.")] int? limit = null, CancellationToken cancellationToken = default) { diff --git a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs index cede7a45..850a05fb 100644 --- a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs +++ b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs @@ -29,19 +29,24 @@ public sealed class TaskWaitMcpTools } [McpServerTool, Description( - "Blocks until at least one of the given tasks leaves Queued/Running, or until timeoutSeconds elapses " + - "(clamped server-side to 900s). Returns immediately if any task is already outside Queued/Running " + - "when called (an unknown id is reported as status \"NotFound\" and counts as changed). Use this instead " + - "of polling get_task in a loop. Pitfall: a planning parent with children goes Running -> " + - "WaitingForChildren while its children are still working, and by default that counts as \"changed\" -- " + - "so waiting on a parent returns immediately even though the work isn't done. Set " + - "treatWaitingForChildrenAsBusy=true to keep waiting through WaitingForChildren; the call then only " + - "returns once the parent reaches WaitingForReview or a terminal status (default: false, unchanged " + - "legacy behavior). Requires the calling claude process to run with MCP_TOOL_TIMEOUT >= 930000 (ms) for " + - "a long wait to actually be held open -- ClaudeDo's own launchers already set this. " + - "Result: { changed: [{ taskId, status }], timedOut }.")] + "Blocks until at least one of the given tasks leaves Queued/Running -- use this instead of " + + "polling get_task in a loop. Returns immediately if a task is already outside Queued/Running " + + "(an unknown id reports status \"NotFound\" and counts as changed). Pitfall: a planning parent " + + "goes Running -> WaitingForChildren while its children are still working, so by default " + + "waiting on a parent returns early; see treatWaitingForChildrenAsBusy. Requires the calling " + + "claude process to run with MCP_TOOL_TIMEOUT >= 930000 (ms) for a long wait to actually be " + + "held open -- ClaudeDo's own launchers already set this.")] public async Task WaitForTaskChange( - string[] taskIds, int timeoutSeconds = 60, bool treatWaitingForChildrenAsBusy = false, + string[] taskIds, + [Description( + "How long to wait, in seconds, before giving up. Clamped server-side to 900s (15 min) " + + "regardless of what's passed.")] + int timeoutSeconds = 60, + [Description( + "When true, WaitingForChildren still counts as busy, so waiting on a planning parent " + + "continues until it reaches WaitingForReview or a terminal status instead of returning " + + "as soon as it leaves Running.")] + bool treatWaitingForChildrenAsBusy = false, CancellationToken cancellationToken = default) { if (taskIds.Length == 0) diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index e6d306d3..4f7ef325 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -989,19 +989,6 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.Equal(10, result.Config.MaxTurns); } - // ── GetTaskStatusValues ─────────────────────────────────────────────────── - - [Fact] - public async Task GetTaskStatusValues_ContainsAllStatuses() - { - var sut = NewService(); - var values = await sut.GetTaskStatusValues(); - var names = values.Select(v => v.Status).ToHashSet(); - - foreach (var status in Enum.GetValues()) - Assert.Contains(status.ToString(), names); - } - // ── ListTasks status filter ─────────────────────────────────────────────── [Fact]