Merge claudedo/ccd650a8d2b04a7092e81ed07c16dbe0

This commit is contained in:
mika kuns
2026-08-05 22:46:37 +02:00
18 changed files with 503 additions and 1 deletions
+21 -1
View File
@@ -4,7 +4,7 @@ using System.Text.Json;
namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial }
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial, MergeHelperHandoff }
/// <summary>
/// How a prompt kind's on-disk override (if any) relates to the bundled default.
@@ -40,6 +40,7 @@ public static class PromptFiles
PromptKind.Refine => "refine.md",
PromptKind.MergeHelper => "merge-helper-system.md",
PromptKind.MergeHelperInitial => "merge-helper-initial.md",
PromptKind.MergeHelperHandoff => "merge-helper-handoff.md",
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
@@ -212,6 +213,7 @@ public static class PromptFiles
PromptKind.Refine => RefineDefault,
PromptKind.MergeHelper => MergeHelperDefault,
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault,
_ => ""
};
@@ -435,6 +437,9 @@ public static class PromptFiles
If a task visibly bundles several independent features, or has a blocker that is not resolved by anything in its own description, do not force it into one description. Propose splitting it to the user; if they agree, create the pieces with add_task/add_subtask and only move the pieces the user confirmed into "surviving tasks" for the phases below. Split only what the task already asks for the "do not invent requirements" rule still applies.
## Handoff
Once every surviving task is enhanced, call handoff_list_handler with this session's task id and the surviving task ids, in the order you intend to run them. That opens a fresh session to carry out phases 35 with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop do not continue into phase 3 yourself.
## Phase 3 Run
Do NOT use run_task_now for a batch there is a single override slot and the second call fails with "override slot busy".
@@ -495,6 +500,21 @@ public static class PromptFiles
When every task is handled, print the summary.
""";
private const string MergeHelperHandoffDefault = """
# List handler handoff
Scope: {scope}
Repo: {repo}
A prior session already read, deduped and enhanced this list's tasks. Pick up at phase 3
for the tasks below their descriptions are already sharpened, so don't redo phases 02.
{tasks}
Start with phase 3 (run), continuing through review/merge and the summary as your
instructions describe.
""";
private const string WeeklyReportDefault = """
You are generating a concise weekly standup report for a software developer,
covering {start} to {end}.
@@ -294,6 +294,7 @@
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
"conptyStarting": "Sitzung wird gestartet…",
"mergeHelperTitle": "Merge-Helfer",
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
"mergeHelperTaskTitle": "Listen-Handler: {0}",
"mergeHelperTaskDescriptionHeader": "Von diesem Lauf bearbeitete Tasks:",
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
@@ -294,6 +294,7 @@
"conptyLaunchFailed": "Couldn't open interactive session: {0}",
"conptyStarting": "Starting session…",
"mergeHelperTitle": "Merge Helper",
"mergeHelperHandoffTitleSuffix": " (Handoff)",
"mergeHelperTaskTitle": "List handler: {0}",
"mergeHelperTaskDescriptionHeader": "Tasks handled by this run:",
"submitForReviewFailed": "Couldn't submit for review: {0}",
@@ -25,6 +25,10 @@ public interface IWorkerClient : INotifyPropertyChanged
/// <summary>A pending question was answered, timed out, or the run ended: (taskId, questionId).</summary>
event Action<string, string>? TaskQuestionResolvedEvent;
/// <summary>A running list-handler session called handoff_list_handler at the end of Phase 2:
/// (handlerTaskId, survivingTaskIds). The UI opens a second ConPTY tile for the same task.</summary>
event Action<string, IReadOnlyList<string>>? HandoffRequestedEvent;
event Action? PrepStartedEvent;
event Action<string>? PrepLineEvent;
event Action<bool>? PrepFinishedEvent;
@@ -90,6 +94,10 @@ public interface IWorkerClient : INotifyPropertyChanged
/// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary>
Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default);
/// <summary>Launch spec for the fresh ConPTY session a merge-helper run hands off to once Phase 2
/// is done -- reuses the SAME handler task id (no new task created).</summary>
Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct = default);
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
+10
View File
@@ -49,6 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string>? TaskUpdatedEvent;
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>>? HandoffRequestedEvent;
public event Action? ConnectionRestoredEvent;
public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? ListUpdatedEvent;
@@ -150,6 +151,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId));
});
_hub.On<string, IReadOnlyList<string>>("HandoffRequested", (taskId, survivingTaskIds) =>
{
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds));
});
_hub.On<string>("WorktreeUpdated", taskId =>
{
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
@@ -531,6 +537,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
=> await _hub.InvokeAsync<string>("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct);
public async Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, ct);
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct);
@@ -20,6 +20,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
private readonly Action<string, string, string, DateTime> _onTaskFinished;
private readonly Action<string> _onTaskUpdated;
private readonly Action _onConnectionRestored;
private readonly Action<string, IReadOnlyList<string>> _onHandoffRequested;
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
// review/merge/status machinery.
@@ -77,6 +78,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_onConnectionRestored = () => { _ = RefreshQueueAsync(); };
_worker.ConnectionRestoredEvent += _onConnectionRestored;
_onHandoffRequested = (taskId, survivingTaskIds) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds); };
_worker.HandoffRequestedEvent += _onHandoffRequested;
_ = RefreshQueueAsync();
}
@@ -243,6 +247,34 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
() => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId))));
}
// List-handler handoff: the running session called handoff_list_handler at the end of Phase 2.
// Opens a SECOND ConPTY tile for the SAME handler task id to carry out Phases 3-5 — deliberately
// NOT deduped by TaskId like OpenMergeHelperConPtySessionAsync above, since the original tile is
// meant to stay open (Mika closes it by hand once he has seen its last message). No new task is
// created here; see InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync.
public async System.Threading.Tasks.Task OpenMergeHelperHandoffConPtySessionAsync(string taskId, IReadOnlyList<string> survivingTaskIds)
{
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
var baseTitle = Loc.T("missionControl.mergeHelperTitle");
var title = baseTitle + Loc.T("missionControl.mergeHelperHandoffTitleSuffix");
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var task = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
if (task is not null)
{
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == task.ListId);
if (list?.Name is { Length: > 0 } name)
title = $"{baseTitle} — {name}{Loc.T("missionControl.mergeHelperHandoffTitleSuffix")}";
}
}
catch { /* best-effort title lookup */ }
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds))));
}
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
// so the tile is on screen (spinner running) while the worker is still preparing the worktree.
private void AddConPtyPane(ConPtyPaneViewModel pane)
@@ -327,6 +359,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_worker.TaskFinishedEvent -= _onTaskFinished;
_worker.TaskUpdatedEvent -= _onTaskUpdated;
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
_worker.HandoffRequestedEvent -= _onHandoffRequested;
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
Panes.CollectionChanged -= OnPanesChanged;
foreach (var c in ConPtySessions.ToList())
+44
View File
@@ -0,0 +1,44 @@
using System.ComponentModel;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record HandoffListHandlerResult(bool Requested, string TaskId, int SurvivingCount);
[McpServerToolType]
public sealed class HandoffMcpTools
{
private readonly TaskRepository _tasks;
private readonly HubBroadcaster _broadcaster;
public HandoffMcpTools(TaskRepository tasks, HubBroadcaster broadcaster)
{
_tasks = tasks;
_broadcaster = broadcaster;
}
[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.")]
public async Task<HandoffListHandlerResult> HandoffListHandler(
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken cancellationToken)
{
if (survivingTaskIds.Count == 0)
throw new InvalidOperationException("survivingTaskIds must contain at least one task id.");
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
foreach (var id in survivingTaskIds)
_ = await _tasks.GetByIdAsync(id, cancellationToken)
?? throw new InvalidOperationException($"Task {id} not found.");
await _broadcaster.HandoffRequested(taskId, survivingTaskIds);
return new HandoffListHandlerResult(true, taskId, survivingTaskIds.Count);
}
}
@@ -32,6 +32,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
public Task TaskQuestionResolved(string taskId, string questionId) =>
_hub.Clients.All.SendAsync("TaskQuestionResolved", taskId, questionId);
// A running list-handler session called handoff_list_handler at the end of Phase 2 -- the UI
// opens a second ConPTY tile for the same handler task id to carry out Phases 3-5.
public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds) =>
_hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds);
public Task ListUpdated(string listId) =>
_hub.Clients.All.SendAsync("ListUpdated", listId);
+10
View File
@@ -751,6 +751,16 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return taskId;
});
// Builds the launch spec for the fresh ConPTY session a merge-helper run hands off to once
// Phase 2 (enhance) is done -- reuses the SAME handler task id (no new task created), only a
// new session dir + handoff kickoff naming the surviving tasks.
public Task<LaunchSpec> GetMergeHelperHandoffLaunchSpec(string taskId, string[] survivingTaskIds) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildForMergeHelperHandoffAsync(taskId, survivingTaskIds, Context.ConnectionAborted);
});
// Starts a planning session (worktree + prompt files + token, task -> Planning) and returns
// the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for
// StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started
+2
View File
@@ -302,6 +302,7 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
externalBuilder.Services.AddScoped<AgentMcpTools>();
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
externalBuilder.Services.AddScoped<HandoffMcpTools>();
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
externalBuilder.Services.AddScoped<QueueStateMcpTools>();
@@ -318,6 +319,7 @@ if (cfg.ExternalMcpPort > 0)
.WithTools<RunHistoryMcpTools>()
.WithTools<AgentMcpTools>()
.WithTools<LifecycleMcpTools>()
.WithTools<HandoffMcpTools>()
.WithTools<AppSettingsMcpTools>()
.WithTools<TaskWaitMcpTools>()
.WithTools<QueueStateMcpTools>()
@@ -253,6 +253,79 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
return new LaunchSpec(repoDir, resolvedClaude, args, env);
}
// Builds the LaunchSpec for the fresh session a merge-helper run hands off to once Phase 2
// (enhance) is done -- SAME handler task id as the run that called handoff_list_handler, so
// HandlerBaseCommit/HandlerHeadCommit and the review range stay untouched; this never creates
// a task. Reuses the merge-helper system prompt unchanged (the phase 3-5 instructions already
// live there) and only writes a fresh handoff kickoff file, in a NEW session dir -- the old
// ConPTY tile keeps running against its own session-dir files untouched.
public async Task<LaunchSpec> BuildForMergeHelperHandoffAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct)
{
if (survivingTaskIds.Count == 0)
throw new InvalidOperationException("No surviving tasks to hand off.");
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var taskRepo = new TaskRepository(ctx);
var listRepo = new ListRepository(ctx);
var handlerTask = await taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task not found: {taskId}");
var list = await listRepo.GetByIdAsync(handlerTask.ListId, ct)
?? throw new KeyNotFoundException($"List not found: {handlerTask.ListId}");
var repoDir = list.WorkingDir;
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
var briefLines = new List<string>();
foreach (var id in survivingTaskIds)
{
var task = await taskRepo.GetByIdAsync(id, ct)
?? throw new KeyNotFoundException($"Task not found: {id}");
briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
}
var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
Directory.CreateDirectory(sessionDir);
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelper), ct);
var briefPath = Path.Combine(sessionDir, "handoff.md");
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperHandoff,
new Dictionary<string, string>
{
["scope"] = $"List: {list.Name}",
["repo"] = repoDir,
["tasks"] = string.Join("\n", briefLines),
}), ct);
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
var listConfig = await listRepo.GetConfigAsync(handlerTask.ListId, ct);
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
var args = new List<string>
{
"--effort", EffortFor(settings, listConfig?.Model),
"--permission-mode", "auto",
"--allowedTools", MergeHelperAllowedTools,
"--add-dir", sessionDir, repoDir,
"--append-system-prompt-file", systemPromptPath,
$"Read the file {briefPath} first. It lists the surviving tasks and their status. " +
"After reading it, begin at phase 3 as your instructions describe.",
};
var env = new Dictionary<string, string>
{
["MCP_TOOL_TIMEOUT"] = "200000",
};
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
@@ -42,4 +42,12 @@ public interface IInteractiveLaunchSpecService
/// is empty or the list has no existing working directory.</summary>
Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct);
/// <summary>Builds a LaunchSpec for the fresh ConPTY session a merge-helper run hands off to
/// once Phase 2 (enhance) is done -- reuses the SAME handler task id (no new task, HandlerBaseCommit
/// untouched) and the unmodified merge-helper system prompt, writing only a fresh handoff kickoff
/// naming the surviving tasks. Throws KeyNotFoundException if the task/list doesn't exist;
/// InvalidOperationException if survivingTaskIds is empty or the list has no working directory.</summary>
Task<LaunchSpec> BuildForMergeHelperHandoffAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct);
}