diff --git a/docs/explore-notes/external-mcp.md b/docs/explore-notes/external-mcp.md index bc28c2f6..3f60b4ee 100644 --- a/docs/explore-notes/external-mcp.md +++ b/docs/explore-notes/external-mcp.md @@ -1,8 +1,8 @@ # External MCP tool surface > **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative. -> Last verified against commit `bdee731` (2026-08-05). -> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/External` +> Last verified against commit `7cfe280` (2026-08-06). +> Drift check: `git log --oneline 7cfe280..HEAD -- src/ClaudeDo.Worker/External` > Stable structure only (no line numbers). See docs/explore-notes/README.md. Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general @@ -71,6 +71,12 @@ Daily prep: `GetDailyPrepCandidates`, `SetMyDay`. UI's "set status freely" affordance uses) but is **refused** for a task with an active worktree, since that would skip `review_task`'s merge. +**`AddTask`** — always creates the task; also returns `possibleDuplicates` (up to 3, id/title/status +only, no descriptions) — open (non-terminal) tasks in the *same list* whose normalized title +overlaps strongly with the new one. Cheap word-overlap heuristic (`ExternalMcpService`'s +`FindPossibleDuplicatesAsync`/`NormalizeTitleWords`), no embeddings/LLM call, no blocking — +the caller just gets a heads-up to relay. `BatchAddTasks` carries the same field per item. + **`ReviewTask`** — `approve` / `reject_rerun` / `reject_park` / `cancel` for a `WaitingForReview` task. Approve is review+merge exactly like the hub's `ApproveReview`: unit merge for parents, worktree merge into optional `targetBranch` for childless tasks. Conflicts diff --git a/src/ClaudeDo.Worker/External/BatchMcpTools.cs b/src/ClaudeDo.Worker/External/BatchMcpTools.cs index 7c8fb8bd..f2372f8a 100644 --- a/src/ClaudeDo.Worker/External/BatchMcpTools.cs +++ b/src/ClaudeDo.Worker/External/BatchMcpTools.cs @@ -7,7 +7,9 @@ public sealed record BatchAddTaskInput(string Title, string? Description = null, public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null); public sealed record BatchGetTaskResult(string Id, bool Found, TaskDto? Task, 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? 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); @@ -62,8 +64,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> BatchAddTasks( string listId, BatchAddTaskInput[] tasks, @@ -82,12 +87,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; diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 962859a6..7aa4cd20 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -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; @@ -47,6 +50,11 @@ public sealed record TaskRefDto( int SortOrder, bool IsMyDay); +// 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 PossibleDuplicates); + public sealed record WorktreeInfoDto( string Path, string Branch, string HeadCommit, string BaseCommit, int Ahead, int Behind, bool IsDirty, string? MergeCommit = null); @@ -182,8 +190,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 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 AddTask( string listId, string title, string? description = null, @@ -200,6 +212,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(), @@ -225,7 +239,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> 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 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 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( diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs index ee7b8830..f1246d43 100644 --- a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -142,6 +142,24 @@ public sealed class BatchMcpToolsTests : IDisposable Assert.Equal(2, inList.Count); } + [Fact] + public async Task BatchAddTasks_SimilarTitleInSameList_ReportsPossibleDuplicate() + { + var listId = await SeedListAsync(); + await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Idle); + var sut = BuildSut(); + + var results = await sut.BatchAddTasks(listId, new[] + { + new BatchAddTaskInput("Settings: MaxTurnsCeiling editierbar machen"), + }, cancellationToken: CancellationToken.None); + + var result = Assert.Single(results); + Assert.True(result.Ok); + Assert.NotNull(result.PossibleDuplicates); + Assert.Single(result.PossibleDuplicates!); + } + [Fact] public async Task BatchGetTasks_MissingId_IsFoundFalseNotError() { diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index b3644b30..5b5e8e2f 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -1518,7 +1518,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None); - var loaded = await _tasks.GetByIdAsync(dto.Id); + var loaded = await _tasks.GetByIdAsync(dto.Task.Id); Assert.Null(loaded!.Model); } @@ -1530,7 +1530,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var dto = await sut.AddTask(listId, "t", model: "HAIKU", cancellationToken: CancellationToken.None); - var loaded = await _tasks.GetByIdAsync(dto.Id); + var loaded = await _tasks.GetByIdAsync(dto.Task.Id); Assert.Equal("haiku", loaded!.Model); } @@ -1544,6 +1544,80 @@ public sealed class ExternalMcpServiceTests : IDisposable () => sut.AddTask(listId, "t", model: "gpt4", cancellationToken: CancellationToken.None)); } + // ── AddTask possible-duplicate check ──────────────────────────────────────── + + [Fact] + public async Task AddTask_SimilarOpenTitleInSameList_IsReportedAsPossibleDuplicate() + { + // The exact pair that motivated this check (bf4cd901 vs f3718cd8, see task write-up). + var listId = await SeedListAsync(); + var existing = await SeedTaskAsync( + listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.WaitingForReview); + var sut = NewService(); + + var result = await sut.AddTask( + listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None); + + var dup = Assert.Single(result.PossibleDuplicates); + Assert.Equal(existing.Id, dup.TaskId); + Assert.Equal(existing.Title, dup.Title); + Assert.Equal("WaitingForReview", dup.Status); + } + + [Fact] + public async Task AddTask_StillCreatesTheTask_EvenWhenAPossibleDuplicateIsFound() + { + var listId = await SeedListAsync(); + await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Idle); + var sut = NewService(); + + var result = await sut.AddTask( + listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None); + + Assert.NotEmpty(result.PossibleDuplicates); + Assert.NotNull(await _tasks.GetByIdAsync(result.Task.Id)); + } + + [Fact] + public async Task AddTask_SimilarTitle_ButExistingTaskIsTerminal_IsNotReported() + { + var listId = await SeedListAsync(); + await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Done); + var sut = NewService(); + + var result = await sut.AddTask( + listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None); + + Assert.Empty(result.PossibleDuplicates); + } + + [Fact] + public async Task AddTask_ClearlyDifferentTitle_IsNotReported() + { + var listId = await SeedListAsync(); + await SeedTaskAsync(listId, "Refactor GitService worktree cleanup", TaskStatus.Idle); + var sut = NewService(); + + var result = await sut.AddTask( + listId, "Add dark mode toggle to the settings page", cancellationToken: CancellationToken.None); + + Assert.Empty(result.PossibleDuplicates); + } + + [Fact] + public async Task AddTask_MoreThanThreeMatches_CapsPossibleDuplicatesAtThree() + { + var listId = await SeedListAsync(); + for (var i = 0; i < 5; i++) + await SeedTaskAsync(listId, $"Fix flaky queue picker test number {i}", TaskStatus.Idle); + var sut = NewService(); + + var result = await sut.AddTask( + listId, "Fix flaky queue picker test number extra", cancellationToken: CancellationToken.None); + + Assert.Equal(3, result.PossibleDuplicates.Count); + } + // ── ContinueTask validation ─────────────────────────────────────────────── [Fact]