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 2bfde47b..a0648a24 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 fa71b9d6..f375c21e 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -88,13 +88,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, }); _seededTaskIds.Add(taskId); // a fresh-task ConPTY spec may write a real brief.md under // ~/.todo-app/task-sessions/ -- clean it up on dispose @@ -562,6 +562,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]