Merge claudedo/05827da5ecde413e9a2fe8edd45c24a8
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<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
|
||||
// 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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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/<taskId> -- 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]
|
||||
|
||||
Reference in New Issue
Block a user