diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index f30933d..011dc98 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -49,6 +49,15 @@ public sealed record WorktreeListItemDto( public sealed record CleanupWorktreeResult( bool Removed, string WorktreePath, bool BranchDeleted); +public sealed record DailyPrepCandidateDto( + string Id, string ListId, string ListName, string Title, string? Description, + bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt); + +public sealed record DailyPrepDataDto( + int MaxTasks, + IReadOnlyList Candidates, + IReadOnlyList CurrentMyDay); + [McpServerToolType] public sealed class ExternalMcpService { @@ -482,6 +491,48 @@ public sealed class ExternalMcpService return new CleanupWorktreeResult(result.Removed, path, result.Removed); } + // ── Daily prep ─────────────────────────────────────────────────────────── + + [McpServerTool, Description( + "Daily prep: returns the open tasks eligible for today's MyDay selection. " + + "candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " + + "currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " + + "maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")] + public async Task GetDailyPrepCandidates(CancellationToken cancellationToken) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); + + var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken); + var excludes = DailyPrepFilter.ParseExcludes(settings.ReportExcludedPaths); + var maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks; + + var idle = await ctx.Tasks + .AsNoTracking() + .Include(t => t.List) + .Where(t => t.Status == TaskStatus.Idle) + .ToListAsync(cancellationToken); + + var currentMyDay = idle + .Where(t => t.IsMyDay) + .OrderBy(t => t.SortOrder) + .Select(ToCandidate) + .ToList(); + + var candidates = idle + .Where(t => !t.IsMyDay + && t.BlockedByTaskId == null + && DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes)) + .OrderBy(t => t.CreatedAt) + .Select(ToCandidate) + .ToList(); + + return new DailyPrepDataDto(maxTasks, candidates, currentMyDay); + } + + private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new( + t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description, + t.IsStarred, t.ScheduledFor, t.CreatedAt); + // ── Private helpers ─────────────────────────────────────────────────────── private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync( @@ -569,3 +620,27 @@ public sealed class ExternalMcpService t.StartedAt, t.FinishedAt); } + +internal static class DailyPrepFilter +{ + public static string[] ParseExcludes(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return []; + try + { + var list = System.Text.Json.JsonSerializer.Deserialize>(json); + return list is null ? [] : list.Select(Normalize).Where(p => p.Length > 0).ToArray(); + } + catch (System.Text.Json.JsonException) { return []; } + } + + public static bool IsIncludedRepo(string? workingDir, string[] excludes) + { + if (string.IsNullOrWhiteSpace(workingDir)) return false; + var norm = Normalize(workingDir); + return !excludes.Any(p => norm.StartsWith(p, StringComparison.OrdinalIgnoreCase)); + } + + private static string Normalize(string path) => + path.Trim().Replace('/', '\\').TrimEnd('\\'); +} diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index e792ff8..09e8200 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -253,4 +253,117 @@ public sealed class ExternalMcpServiceTests : IDisposable await Assert.ThrowsAsync(() => sut.DeleteTask("does-not-exist", CancellationToken.None)); } + + private async Task SeedAppSettingsAsync(string? reportExcludedPaths, int dailyPrepMaxTasks = 5) + { + var settings = new AppSettingsEntity + { + Id = AppSettingsEntity.SingletonId, + ReportExcludedPaths = reportExcludedPaths, + DailyPrepMaxTasks = dailyPrepMaxTasks, + }; + // Upsert via AppSettingsRepository + await using var ctx = _db.CreateContext(); + var repo = new AppSettingsRepository(ctx); + await repo.UpdateAsync(settings); + } + + [Fact] + public async Task GetDailyPrepCandidates_filters_by_status_block_and_excluded_repo() + { + // List 1: included repo D:\work\repo — 4 tasks seeded + var listId1 = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId1, Name = "Work", WorkingDir = @"D:\work\repo", CreatedAt = DateTime.UtcNow }); + + // idle, not blocked, not MyDay → should be candidate + var idleUnblocked = new TaskEntity + { + Id = "idle-unblocked", + ListId = listId1, + Title = "Idle unblocked", + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(idleUnblocked); + + // idle but blocked → excluded (BlockedByTaskId references idle-unblocked as its predecessor) + var idleBlocked = new TaskEntity + { + Id = "idle-blocked", + ListId = listId1, + Title = "Idle blocked", + Status = TaskStatus.Idle, + BlockedByTaskId = "idle-unblocked", + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(idleBlocked); + + // Done → excluded + var doneTask = new TaskEntity + { + Id = "done-task", + ListId = listId1, + Title = "Done task", + Status = TaskStatus.Done, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(doneTask); + + // idle, IsMyDay → goes into currentMyDay, not candidates + var myDayTask = new TaskEntity + { + Id = "myday-task", + ListId = listId1, + Title = "MyDay task", + Status = TaskStatus.Idle, + IsMyDay = true, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(myDayTask); + + // List 2: excluded repo C:\Private\secret + var listId2 = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId2, Name = "Secret", WorkingDir = @"C:\Private\secret", CreatedAt = DateTime.UtcNow }); + + var excludedRepoTask = new TaskEntity + { + Id = "excluded-repo-task", + ListId = listId2, + Title = "Excluded repo", + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(excludedRepoTask); + + // List 3: no WorkingDir → excluded + var listId3 = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId3, Name = "NoRepo", WorkingDir = null, CreatedAt = DateTime.UtcNow }); + + var noRepoTask = new TaskEntity + { + Id = "no-repo-task", + ListId = listId3, + Title = "No repo", + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(noRepoTask); + + await SeedAppSettingsAsync(@"[""C:\\Private""]", dailyPrepMaxTasks: 5); + + var sut = BuildSut(CreateQueue()); + var result = await sut.GetDailyPrepCandidates(CancellationToken.None); + + Assert.Single(result.Candidates); + Assert.Equal("idle-unblocked", result.Candidates[0].Id); + Assert.Single(result.CurrentMyDay); + Assert.Equal("myday-task", result.CurrentMyDay[0].Id); + Assert.Equal(5, result.MaxTasks); + } }