feat(daily-prep): add get_daily_prep_candidates MCP tool

This commit is contained in:
mika kuns
2026-06-03 16:15:27 +02:00
parent 3c66d65160
commit 0bb809445e
2 changed files with 188 additions and 0 deletions

View File

@@ -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<DailyPrepCandidateDto> Candidates,
IReadOnlyList<DailyPrepCandidateDto> 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<DailyPrepDataDto> 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<List<string>>(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('\\');
}