From b38b0857dd766562b478aa63cd5b494f1c20590a Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 5 Aug 2026 10:48:37 +0200 Subject: [PATCH] feat(worker): render task descriptions into the list-handler brief Phase 0 forced a batch_get_tasks full-fetch across every task just to see descriptions, which blew past the client's token limit on larger lists. brief.md lives on disk and has no such limit, so descriptions now render there directly (fenced with an extended backtick run, indented under the list bullet, so embedded headings/lists/code fences can't break the task list's own structure). Phase 0 now treats the brief as the primary source and only falls back to batch_get_tasks for fields it doesn't carry. --- src/ClaudeDo.Data/PromptFiles.cs | 2 +- .../Runner/InteractiveLaunchSpecService.cs | 35 +++++++- tests/ClaudeDo.Data.Tests/PromptFilesTests.cs | 9 +++ .../InteractiveLaunchSpecServiceTests.cs | 80 ++++++++++++++++++- 4 files changed, 122 insertions(+), 4 deletions(-) diff --git a/src/ClaudeDo.Data/PromptFiles.cs b/src/ClaudeDo.Data/PromptFiles.cs index d2551f76..b1a9cc60 100644 --- a/src/ClaudeDo.Data/PromptFiles.cs +++ b/src/ClaudeDo.Data/PromptFiles.cs @@ -236,7 +236,7 @@ public static class PromptFiles Work the five phases in order. Do not start a phase before the previous one is finished. ## Phase 0 — Read everything - Call batch_get_tasks with every id from the brief and read each task's title, description, status and parent/child links. Do not act on any single task before you have read them all — Phase 1 needs the whole set in view. + The brief is the primary source: it already lists every task's title, id, status and full description. Read it in full before acting. Only call batch_get_tasks if you need something the brief does not carry for a specific task, e.g. parent/child links. Do not act on any single task before you have read them all — Phase 1 needs the whole set in view. ## Phase 1 — Dedupe Compare the tasks pairwise for overlap: same goal stated twice, one task fully contained in another, two tasks that would edit the same thing for the same reason. diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index 2c5d07cb..254e135d 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -206,7 +206,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService { var task = await taskRepo.GetByIdAsync(id, ct) ?? throw new KeyNotFoundException($"Task not found: {id}"); - briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})"); + briefLines.Add(RenderBriefEntry(task)); } var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString()); @@ -253,6 +253,39 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService return new LaunchSpec(repoDir, resolvedClaude, args, env); } + // Renders one task as a brief list item. A description can itself be arbitrary Markdown + // (headings, lists, fenced code) — those must not merge into the brief's own task list, so + // the description is placed in a fenced code block indented to the list item's continuation + // column (2 spaces, matching "- "). That keeps CommonMark parsing the fence as part of THIS + // bullet rather than breaking the list, while the code fence itself stops any inner heading + // or list syntax from being interpreted. The fence length is extended past the longest run of + // backticks already present in the description so an embedded ``` block can't prematurely + // close it. + private static string RenderBriefEntry(TaskEntity task) + { + var header = $"- [{task.Status}] {task.Title} (id: {task.Id})"; + var description = task.Description?.Trim(); + if (string.IsNullOrEmpty(description)) return header; + + var fence = new string('`', Math.Max(3, LongestBacktickRun(description) + 1)); + var lines = new List(4) { header, $" {fence}" }; + lines.AddRange(description.Replace("\r\n", "\n").Split('\n').Select(line => $" {line}")); + lines.Add($" {fence}"); + return string.Join("\n", lines); + } + + private static int LongestBacktickRun(string text) + { + var max = 0; + var current = 0; + foreach (var ch in text) + { + current = ch == '`' ? current + 1 : 0; + if (current > max) max = current; + } + return max; + } + // Creates the ClaudeDo task that hosts a list-handler run (Mission Control's "Let Claude // handle it") and stamps the list repo's current HEAD as the review range's base commit. // The handler never gets its own worktree -- it commits straight to the list's working diff --git a/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs b/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs index 9f989c5b..dca6c205 100644 --- a/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs +++ b/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs @@ -87,6 +87,15 @@ public class PromptFilesTests Assert.DoesNotContain("whenever you are unsure", d); } + [Fact] + public void DefaultFor_merge_helper_phase0_treats_brief_as_primary_source() + { + var d = PromptFiles.DefaultFor(PromptKind.MergeHelper); + Assert.Contains("primary source", d, StringComparison.OrdinalIgnoreCase); + Assert.Contains("batch_get_tasks", d); + Assert.Contains("Do not act on any single task before you have read", d); + } + [Fact] public void DefaultFor_merge_helper_initial_has_repo_token() { diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index 3100cad3..8d3d2491 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -84,13 +84,13 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable return listId; } - private async Task SeedTaskAsync(string taskId, string listId, TaskStatus status, string? sessionSkillsJson = null, string title = "T") + private async Task SeedTaskAsync(string taskId, string listId, TaskStatus status, string? sessionSkillsJson = null, string title = "T", string? description = null) { using var ctx = _db.CreateContext(); await new TaskRepository(ctx).AddAsync(new TaskEntity { Id = taskId, ListId = listId, Title = title, Status = status, - CreatedAt = DateTime.UtcNow, SessionSkills = sessionSkillsJson, + CreatedAt = DateTime.UtcNow, SessionSkills = sessionSkillsJson, Description = description, }); } @@ -478,6 +478,82 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.Contains(t2, brief); } + [Fact] + public async Task BuildForMergeHelperAsync_BriefIncludesTaskDescription() + { + var repo = Path.Combine(_tempDir, "repoDesc"); + Directory.CreateDirectory(repo); + + var listId = await SeedListAsync(workingDir: repo, name: "Alpha"); + var t1 = Guid.NewGuid().ToString(); + await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "First task", + description: "Do the thing carefully and report back."); + + var svc = BuildService(); + var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, CancellationToken.None); + var sessionDir = TrackSessionDir(spec); + + var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); + Assert.Contains("Do the thing carefully and report back.", brief); + } + + [Fact] + public async Task BuildForMergeHelperAsync_TaskWithoutDescription_RendersCleanly() + { + var repo = Path.Combine(_tempDir, "repoNoDesc"); + Directory.CreateDirectory(repo); + + var listId = await SeedListAsync(workingDir: repo, name: "Alpha"); + var t1 = Guid.NewGuid().ToString(); + await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "No description task", description: null); + + var svc = BuildService(); + var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, CancellationToken.None); + var sessionDir = TrackSessionDir(spec); + + var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); + var line = brief.Split('\n').Single(l => l.Contains("No description task")); + Assert.Equal($"- [Idle] No description task (id: {t1})", line.TrimEnd('\r')); + } + + [Fact] + public async Task BuildForMergeHelperAsync_BriefDescriptionWithCodeFenceAndHeadingsKeepsTaskListRecognizable() + { + var repo = Path.Combine(_tempDir, "repoMd"); + Directory.CreateDirectory(repo); + + var listId = await SeedListAsync(workingDir: repo, name: "Alpha"); + var t1 = Guid.NewGuid().ToString(); + var t2 = Guid.NewGuid().ToString(); + var trickyDescription = """ + # Heading inside description + + - a nested list item + - another one + + ```csharp + var x = "fenced code block"; + ``` + """; + await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "First task", description: trickyDescription); + await SeedTaskAsync(t2, listId, TaskStatus.WaitingForReview, title: "Second task", description: "plain description"); + + var svc = BuildService(); + var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None); + var sessionDir = TrackSessionDir(spec); + + var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); + var lines = brief.Replace("\r\n", "\n").Split('\n'); + + // The two task bullets must still be recognizable top-level list items, unbroken + // by the embedded heading/list/code-fence in the first task's description. + Assert.Contains(lines, l => l == $"- [Idle] First task (id: {t1})"); + Assert.Contains(lines, l => l == $"- [WaitingForReview] Second task (id: {t2})"); + Assert.Contains("Heading inside description", brief); + Assert.Contains("fenced code block", brief); + Assert.Contains("var x = \"fenced code block\";", brief); + } + // ── CreateMergeHelperTaskAsync ── [Fact]