Merge claudedo/a4834991067a479fabfb3226abcd4b9f

This commit is contained in:
mika kuns
2026-08-11 09:28:22 +02:00
11 changed files with 240 additions and 63 deletions
+1
View File
@@ -567,6 +567,7 @@ public static class PromptFiles
Start with phase 3 (run), continuing through review/merge and the summary as your
instructions describe.
{finalNote}
""";
private const string WeeklyReportDefault = """
+16 -9
View File
@@ -1,11 +1,12 @@
using System.ComponentModel;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record HandoffListHandlerResult(bool Requested, string TaskId, int SurvivingCount);
public sealed record HandoffListHandlerResult(bool Requested, string TaskId, int SurvivingCount, string NextPhase);
[McpServerToolType]
public sealed class HandoffMcpTools
@@ -20,16 +21,22 @@ public sealed class HandoffMcpTools
}
[McpServerTool, Description(
"Call at the end of Phase 2 of the list handler (\"Let Claude handle it\") to hand this run off " +
"to a fresh ConPTY session that carries out Phases 3-5, without dragging along this session's " +
"dedupe/rewrite context. Reuses the SAME handler task -- no new task is created, and " +
"HandlerBaseCommit is untouched. The current tile stays open; you must end your own turn " +
"immediately after calling this.")]
"Call at the end of a list-handler (\"Let Claude handle it\") phase to hand this run off to a " +
"fresh ConPTY session that carries out the next phase, without dragging along this session's " +
"own context. Reuses the SAME handler task -- no new task is created, and HandlerBaseCommit is " +
"untouched. nextPhase picks the next session's role: \"wait\" (queue + wait; the normal handoff " +
"after triage) or \"merge\" (review + merge; the normal handoff after wait) -- \"wait_final\"/" +
"\"merge_final\" are only for a merge session that started reruns and must eventually stop the " +
"chain instead of restarting again. An unrecognized value is rejected. The current tile stays " +
"open; you must end your own turn immediately after calling this.")]
public async Task<HandoffListHandlerResult> HandoffListHandler(
[Description("This session's own handler task id.")] string taskId,
[Description("The tasks that made it past dedupe, in the order to run them.")] IReadOnlyList<string> survivingTaskIds,
CancellationToken cancellationToken)
[Description("Next session's phase: wait | merge | wait_final | merge_final.")] string nextPhase = "wait",
CancellationToken cancellationToken = default)
{
MergeHelperPhase.Validate(nextPhase);
if (survivingTaskIds.Count == 0)
throw new InvalidOperationException("survivingTaskIds must contain at least one task id.");
@@ -40,7 +47,7 @@ public sealed class HandoffMcpTools
_ = await _tasks.GetByIdAsync(id, cancellationToken)
?? throw new InvalidOperationException($"Task {id} not found.");
await _broadcaster.HandoffRequested(taskId, survivingTaskIds);
return new HandoffListHandlerResult(true, taskId, survivingTaskIds.Count);
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, nextPhase);
return new HandoffListHandlerResult(true, taskId, survivingTaskIds.Count, nextPhase);
}
}
+2 -2
View File
@@ -34,8 +34,8 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
// 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 HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) =>
_hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase);
public Task ListUpdated(string listId) =>
_hub.Clients.All.SendAsync("ListUpdated", listId);
+2 -2
View File
@@ -855,11 +855,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
// 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(() =>
public Task<LaunchSpec> GetMergeHelperHandoffLaunchSpec(string taskId, string[] survivingTaskIds, string nextPhase) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildForMergeHelperHandoffAsync(taskId, survivingTaskIds, Context.ConnectionAborted);
return _interactiveLaunchSpec.BuildForMergeHelperHandoffAsync(taskId, survivingTaskIds, nextPhase, Context.ConnectionAborted);
});
// Starts a planning session (worktree + prompt files + token, task -> Planning) and returns
@@ -1,3 +1,4 @@
using System.Diagnostics;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
@@ -204,9 +205,10 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
// Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll,
// diff, review/merge, continue/abort merge), read/search, Edit + Bash for hand-resolving
// conflict markers the MCP tools left behind, and web/skill lookups.
// conflict markers the MCP tools left behind, web/skill lookups, and Task so the Merge phase
// can delegate diff reviews to sonnet subagents instead of reading every diff itself.
private const string MergeHelperAllowedTools =
"mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill";
"mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task";
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct)
{
@@ -255,13 +257,16 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
// (--allowedTools, --add-dir) first, then a single-value flag, then the single-line
// positional kickoff LAST — a multi-line positional prompt truncates at the first
// newline, so the full multi-line brief travels via the file exposed through --add-dir.
var listConfig = await listRepo.GetConfigAsync(listId, ct);
// Model/effort/permission-mode are fixed to the Triage role -- the list config's model no
// longer influences a handler session at all, it would otherwise apply the LIST's effort
// preset to a completely different model.
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
var args = new List<string>
{
"--effort", EffortFor(settings, listConfig?.Model),
"--permission-mode", "auto",
"--model", ModelRegistry.HandlerTriageAlias,
"--effort", EffortFor(settings, ModelRegistry.HandlerTriageAlias),
"--permission-mode", PermissionModeResolver.Resolve(ModelRegistry.HandlerTriageAlias, "auto"),
"--allowedTools", MergeHelperAllowedTools,
"--add-dir", sessionDir, repoDir,
"--append-system-prompt-file", systemPromptPath,
@@ -277,19 +282,22 @@ 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
// Builds the LaunchSpec for the fresh session a merge-helper run hands off to for the given
// phase -- 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. Uses the MergeHelperExecute system prompt (phases 3-5 only) rather than the Triage
// one this run started with, so the handoff session carries no dedupe/enhance instructions it
// would have to ignore. Writes a fresh handoff kickoff file in a NEW session dir -- the old
// ConPTY tile keeps running against its own session-dir files untouched.
// a task. nextPhase picks the system prompt (MergeHelperWait for wait/wait_final,
// MergeHelperMerge for merge/merge_final) so the handoff session only ever carries the
// instructions for the role it is actually about to run, never the Triage dedupe/enhance ones.
// 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)
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct)
{
if (survivingTaskIds.Count == 0)
throw new InvalidOperationException("No surviving tasks to hand off.");
var phase = ResolveHandoffPhase(nextPhase);
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var taskRepo = new TaskRepository(ctx);
var listRepo = new ListRepository(ctx);
@@ -316,7 +324,15 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
Directory.CreateDirectory(sessionDir);
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelperExecute), ct);
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(phase.PromptKind), ct);
// A "_final" round differs from its non-final counterpart in exactly one rendered line --
// the same system prompt and model drive both -- marking the final round and forbidding a
// further rerun, so the chain is bounded structurally rather than by prompt appeal alone.
var finalNote = phase.IsFinal
? "\nThis is the FINAL round of this list-handler run -- do not start any further reruns; " +
"merge what you can, report the rest, and print the summary."
: "";
var briefPath = Path.Combine(sessionDir, "handoff.md");
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperHandoff,
@@ -325,23 +341,24 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
["scope"] = $"List: {list.Name}",
["repo"] = repoDir,
["tasks"] = string.Join("\n", briefLines),
["finalNote"] = finalNote,
}), 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",
"--model", phase.Model,
"--effort", EffortFor(settings, phase.Model),
"--permission-mode", PermissionModeResolver.Resolve(phase.Model, "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.",
$"After reading it, begin at {phase.PhaseLabel} as your instructions describe.",
};
var env = new Dictionary<string, string>
@@ -443,6 +460,23 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
return handlerTask.Id;
}
// Maps a handoff nextPhase to the system prompt/model the next session runs under, plus the
// "phase N" label used in its short positional kickoff. wait/wait_final share MergeHelperWait
// + HandlerWaitAlias; merge/merge_final share MergeHelperMerge + HandlerMergeAlias -- only
// IsFinal differs between a phase and its "_final" counterpart.
private static (PromptKind PromptKind, string Model, string PhaseLabel, bool IsFinal) ResolveHandoffPhase(string nextPhase)
{
MergeHelperPhase.Validate(nextPhase);
return nextPhase switch
{
MergeHelperPhase.Wait => (PromptKind.MergeHelperWait, ModelRegistry.HandlerWaitAlias, "phase 3", false),
MergeHelperPhase.WaitFinal => (PromptKind.MergeHelperWait, ModelRegistry.HandlerWaitAlias, "phase 3", true),
MergeHelperPhase.Merge => (PromptKind.MergeHelperMerge, ModelRegistry.HandlerMergeAlias, "phase 4", false),
MergeHelperPhase.MergeFinal => (PromptKind.MergeHelperMerge, ModelRegistry.HandlerMergeAlias, "phase 4", true),
_ => throw new UnreachableException(),
};
}
// The reasoning effort configured for a model in Settings → General. Falls back to the shipped
// preset for that model, so a missing/malformed settings row can never block a launch.
private static string EffortFor(AppSettingsEntity settings, string? model)
@@ -46,11 +46,15 @@ public interface IInteractiveLaunchSpecService
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>
/// <summary>Builds a LaunchSpec for the fresh ConPTY session a merge-helper run hands off to for
/// the given phase -- reuses the SAME handler task id (no new task, HandlerBaseCommit untouched),
/// writing only a fresh handoff kickoff naming the surviving tasks. nextPhase selects both the
/// system prompt and the model: "wait"/"wait_final" -> MergeHelperWait + HandlerWaitAlias,
/// "merge"/"merge_final" -> MergeHelperMerge + HandlerMergeAlias; the "_final" variants render an
/// extra line in the handoff kickoff marking the final round and forbidding further reruns. Throws
/// ArgumentException for an unrecognized nextPhase; 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);
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct);
}
@@ -0,0 +1,21 @@
namespace ClaudeDo.Worker.Runner;
/// <summary>The list handler's phase parameter, threaded from handoff_list_handler (External)
/// through HubBroadcaster/WorkerHub down to InteractiveLaunchSpecService, which picks the next
/// session's system prompt and model from it. Validated centrally here so an unknown value is
/// rejected outright instead of silently falling back to a default.</summary>
public static class MergeHelperPhase
{
public const string Wait = "wait";
public const string Merge = "merge";
public const string WaitFinal = "wait_final";
public const string MergeFinal = "merge_final";
public static readonly IReadOnlyList<string> All = new[] { Wait, Merge, WaitFinal, MergeFinal };
public static void Validate(string phase)
{
if (!All.Contains(phase, StringComparer.Ordinal))
throw new ArgumentException($"Unknown list-handler phase '{phase}'. Allowed: {string.Join(", ", All)}.");
}
}