Merge branch 'claudedo/c1d2a92d8d8c4705ac93d5f0b2cacaa0'

This commit is contained in:
mika kuns
2026-08-06 11:38:32 +02:00
5 changed files with 198 additions and 10 deletions
+10 -5
View File
@@ -10,7 +10,9 @@ public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOr
// taskFull is populated when found and includeDescription=true (full task incl.
// Description/Result). Both are null when found=false.
public sealed record BatchGetTaskResult(string Id, bool Found, TaskRefDto? Task, TaskDto? TaskFull, string? Error);
public sealed record BatchAddTaskResult(int Index, string Title, bool Ok, TaskRefDto? Task, string? Error);
public sealed record BatchAddTaskResult(
int Index, string Title, bool Ok, TaskRefDto? Task,
IReadOnlyList<PossibleDuplicateDto>? PossibleDuplicates, string? Error);
public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error);
public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error);
public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error);
@@ -76,8 +78,11 @@ public sealed class BatchMcpTools
"Create many tasks in one list at once. Each item: { title, description?, model? } " +
"(model: haiku|sonnet|opus, blank = inherit list/global default). " +
"queueImmediately enqueues every created task. " +
"Returns one result per item: { index, title, ok, task, error }; task is a lean reference " +
"(id, listId, title, status, sortOrder, isMyDay), not the description you just sent. Max 100 items.")]
"Returns one result per item: { index, title, ok, task, possibleDuplicates, error }; task is " +
"a lean reference (id, listId, title, status, sortOrder, isMyDay), not the description you just " +
"sent. Each item is always created — possibleDuplicates is a non-blocking heads-up (up to 3 open " +
"tasks in the same list with a strongly overlapping title, id/title/status only); check it and " +
"mention any hit to the caller, but do not treat it as an error. Max 100 items.")]
public async Task<IReadOnlyList<BatchAddTaskResult>> BatchAddTasks(
string listId,
BatchAddTaskInput[] tasks,
@@ -96,12 +101,12 @@ public sealed class BatchMcpTools
var created = await _svc.AddTask(
listId, item.Title, item.Description, createdBy,
queueImmediately, item.Model, cancellationToken);
results.Add(new BatchAddTaskResult(i, item.Title, true, created, null));
results.Add(new BatchAddTaskResult(i, item.Title, true, created.Task, created.PossibleDuplicates, null));
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
results.Add(new BatchAddTaskResult(i, item.Title, false, null, ex.Message));
results.Add(new BatchAddTaskResult(i, item.Title, false, null, null, ex.Message));
}
}
return results;
+88 -3
View File
@@ -1,5 +1,8 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
@@ -56,6 +59,12 @@ public sealed record ListTasksResult(
IReadOnlyList<TaskRefDto>? Tasks,
IReadOnlyList<TaskDto>? TasksFull);
// Deliberately small: no descriptions, capped candidate count (see AddTask's duplicate check).
public sealed record PossibleDuplicateDto(string TaskId, string Title, string Status);
public sealed record AddTaskResult(TaskRefDto Task, IReadOnlyList<PossibleDuplicateDto> PossibleDuplicates);
public sealed record WorktreeInfoDto(
string Path, string Branch, string HeadCommit, string BaseCommit,
int Ahead, int Behind, bool IsDirty, string? MergeCommit = null);
@@ -209,8 +218,12 @@ public sealed class ExternalMcpService
"Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " +
"'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " +
"Leave model null to inherit the list/global default. " +
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay) — not the description you just sent.")]
public async Task<TaskRefDto> AddTask(
"Returns { task, possibleDuplicates }: task is a lean reference (id, listId, title, status, " +
"sortOrder, isMyDay) — not the description you just sent. The task is always created — " +
"possibleDuplicates is a non-blocking heads-up (up to 3 open tasks in the same list with a " +
"strongly overlapping title, id/title/status only); check it and mention any hit to the caller, " +
"but do not treat it as an error.")]
public async Task<AddTaskResult> AddTask(
string listId,
string title,
string? description = null,
@@ -227,6 +240,8 @@ public sealed class ExternalMcpService
var list = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
var possibleDuplicates = await FindPossibleDuplicatesAsync(listId, title, cancellationToken);
var entity = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
@@ -252,7 +267,77 @@ public sealed class ExternalMcpService
}
await _broadcaster.TaskUpdated(entity.Id);
return ToRefDto(entity);
return new AddTaskResult(ToRefDto(entity), possibleDuplicates);
}
// Non-terminal: a task still open enough that a new, similarly-titled task might be a duplicate
// of it. Done/Failed/Cancelled tasks are retired and never flagged.
private static readonly TaskStatus[] NonTerminalStatuses =
[
TaskStatus.Idle, TaskStatus.Queued, TaskStatus.Running,
TaskStatus.WaitingForChildren, TaskStatus.WaitingForReview,
];
private const int MaxPossibleDuplicates = 3;
// Cheap, dependency-free "near-duplicate title" heuristic — no embeddings, no LLM call, just a
// normalized-word overlap coefficient (shared significant words / smaller word-set size, not
// Jaccard). Overlap-coefficient beats Jaccard here because titles in this project vary wildly in
// length (a terse one-liner vs. a fully-spelled-out German sentence) — dividing by the union
// would wash out a real match against a longer title. Threshold tuned against the pair that
// motivated this check: "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal" vs.
// "Settings: MaxTurnsCeiling editierbar machen" (>=2 shared significant words, >=50% overlap of
// the shorter title's words) without over-firing on titles that merely share one generic word.
private async Task<IReadOnlyList<PossibleDuplicateDto>> FindPossibleDuplicatesAsync(
string listId, string title, CancellationToken ct)
{
var newWords = NormalizeTitleWords(title);
if (newWords.Count == 0) return [];
var candidates = await _tasks.GetByListIdAsync(listId, ct);
return candidates
.Where(t => NonTerminalStatuses.Contains(t.Status))
.Select(t => (Task: t, Words: NormalizeTitleWords(t.Title)))
.Select(x => (x.Task, Shared: newWords.Count(x.Words.Contains), x.Words))
.Where(x => x.Shared >= 2 && x.Words.Count > 0
&& (double)x.Shared / Math.Min(newWords.Count, x.Words.Count) >= 0.5)
.OrderByDescending(x => (double)x.Shared / Math.Min(newWords.Count, x.Words.Count))
.ThenByDescending(x => x.Shared)
.Take(MaxPossibleDuplicates)
.Select(x => new PossibleDuplicateDto(x.Task.Id, x.Task.Title, x.Task.Status.ToString()))
.ToList();
}
private static readonly Regex NonWordChars = new(@"[^a-z0-9]+", RegexOptions.Compiled);
// Common structural filler that would otherwise dominate the overlap score across most titles
// in this project's lists without indicating a real duplicate (German articles/prepositions
// plus the recurring "mcp"/"task(s)"/"cleanup" nouns called out in the task write-up).
private static readonly HashSet<string> TitleStopWords = new(StringComparer.Ordinal)
{
"der", "die", "das", "und", "von", "auf", "mit", "fur", "ein", "eine",
"ist", "sind", "oder", "nicht", "mcp", "task", "tasks", "cleanup",
};
// Lowercases, transliterates German umlauts/ß to their digraph spelling (ä→ae, ß→ss, ...) so
// "Prüfung" and "Pruefung" normalize to the same token, strips remaining diacritics/punctuation,
// then drops short (<3 char) and stopword tokens.
private static HashSet<string> NormalizeTitleWords(string title)
{
var s = title.ToLowerInvariant()
.Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue").Replace("ß", "ss");
s = s.Normalize(NormalizationForm.FormD);
var sb = new StringBuilder(s.Length);
foreach (var ch in s)
if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark)
sb.Append(ch);
var ascii = NonWordChars.Replace(sb.ToString(), " ");
return ascii.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length >= 3 && !TitleStopWords.Contains(w))
.ToHashSet(StringComparer.Ordinal);
}
[McpServerTool, Description(