Merge claudedo/05827da5ecde413e9a2fe8edd45c24a8

This commit is contained in:
mika kuns
2026-08-05 11:03:56 +02:00
4 changed files with 122 additions and 4 deletions
+1 -1
View File
@@ -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. Work the five phases in order. Do not start a phase before the previous one is finished.
## Phase 0 Read everything ## 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 ## 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. 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.
@@ -206,7 +206,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
{ {
var task = await taskRepo.GetByIdAsync(id, ct) var task = await taskRepo.GetByIdAsync(id, ct)
?? throw new KeyNotFoundException($"Task not found: {id}"); ?? 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()); 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); 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<string>(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 // 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. // 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 // The handler never gets its own worktree -- it commits straight to the list's working
@@ -87,6 +87,15 @@ public class PromptFilesTests
Assert.DoesNotContain("whenever you are unsure", d); 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] [Fact]
public void DefaultFor_merge_helper_initial_has_repo_token() public void DefaultFor_merge_helper_initial_has_repo_token()
{ {
@@ -88,13 +88,13 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
return listId; 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(); using var ctx = _db.CreateContext();
await new TaskRepository(ctx).AddAsync(new TaskEntity await new TaskRepository(ctx).AddAsync(new TaskEntity
{ {
Id = taskId, ListId = listId, Title = title, Status = status, 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 _seededTaskIds.Add(taskId); // a fresh-task ConPTY spec may write a real brief.md under
// ~/.todo-app/task-sessions/<taskId> -- clean it up on dispose // ~/.todo-app/task-sessions/<taskId> -- clean it up on dispose
@@ -562,6 +562,82 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Contains(t2, brief); 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 ── // ── CreateMergeHelperTaskAsync ──
[Fact] [Fact]