using System.IO; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace AxCopilot.Services.Agent; /// 작업 폴더 내 TODO/태스크 추적 도구. public class TaskTrackerTool : IAgentTool { public string Name => "task_tracker"; public string Description => "Track tasks/TODOs in the working folder. Actions: " + "'scan' — scan source files for TODO/FIXME/HACK/BUG comments; " + "'add' — add a task to .ax/tasks.json; " + "'list' — list tasks from .ax/tasks.json; " + "'done' — mark a task as completed."; public ToolParameterSchema Parameters => new() { Properties = new() { ["action"] = new() { Type = "string", Description = "Action: scan, add, list, done", Enum = ["scan", "add", "list", "done"], }, ["title"] = new() { Type = "string", Description = "Task title (for 'add')", }, ["priority"] = new() { Type = "string", Description = "Priority: high, medium, low (for 'add', default: medium)", }, ["id"] = new() { Type = "integer", Description = "Task ID (for 'done')", }, ["extensions"] = new() { Type = "string", Description = "File extensions to scan, comma-separated (default: .cs,.py,.js,.ts,.java,.cpp,.c)", }, }, Required = ["action"], }; private const string TaskFileName = ".ax/tasks.json"; public Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default) { var action = args.GetProperty("action").GetString() ?? ""; try { return action switch { "scan" => Task.FromResult(ScanTodos(args, context)), "add" => Task.FromResult(AddTask(args, context)), "list" => Task.FromResult(ListTasks(context)), "done" => Task.FromResult(MarkDone(args, context)), _ => Task.FromResult(ToolResult.Fail($"Unknown action: {action}")), }; } catch (Exception ex) { return Task.FromResult(ToolResult.Fail($"태스크 추적 오류: {ex.Message}")); } } private static ToolResult ScanTodos(JsonElement args, AgentContext context) { var extStr = args.TryGetProperty("extensions", out var e) ? e.GetString() ?? ".cs,.py,.js,.ts,.java,.cpp,.c" : ".cs,.py,.js,.ts,.java,.cpp,.c"; var exts = new HashSet( extStr.Split(',').Select(s => s.Trim().StartsWith('.') ? s.Trim() : "." + s.Trim()), StringComparer.OrdinalIgnoreCase); var patterns = new[] { "TODO", "FIXME", "HACK", "BUG", "XXX" }; var results = new List<(string File, int Line, string Tag, string Text)>(); var workDir = context.WorkFolder; if (!Directory.Exists(workDir)) return ToolResult.Fail($"작업 폴더 없음: {workDir}"); foreach (var file in Directory.EnumerateFiles(workDir, "*", SearchOption.AllDirectories)) { if (!exts.Contains(Path.GetExtension(file))) continue; if (file.Contains("bin") || file.Contains("obj") || file.Contains("node_modules")) continue; var lines = TextFileCodec.SplitLines(TextFileCodec.ReadAllText(file).Text); var lineNum = 0; foreach (var line in lines) { lineNum++; foreach (var pat in patterns) { var idx = line.IndexOf(pat, StringComparison.OrdinalIgnoreCase); if (idx >= 0) { var text = line[(idx + pat.Length)..].TrimStart(':', ' '); if (text.Length > 100) text = text[..100] + "..."; var relPath = Path.GetRelativePath(workDir, file); results.Add((relPath, lineNum, pat, text)); break; } } if (results.Count >= 200) break; } if (results.Count >= 200) break; } if (results.Count == 0) return ToolResult.Ok("TODO/FIXME 코멘트가 발견되지 않았습니다."); var sb = new StringBuilder(); sb.AppendLine($"발견된 TODO 코멘트: {results.Count}개"); foreach (var (file, line, tag, text) in results) sb.AppendLine($" [{tag}] {file}:{line} — {text}"); return ToolResult.Ok(sb.ToString()); } private static ToolResult AddTask(JsonElement args, AgentContext context) { var title = args.TryGetProperty("title", out var t) ? t.GetString() ?? "" : ""; if (string.IsNullOrEmpty(title)) return ToolResult.Fail("'title'이 필요합니다."); var priority = args.TryGetProperty("priority", out var p) ? p.GetString() ?? "medium" : "medium"; var tasks = LoadTasks(context); var maxId = tasks.Count > 0 ? tasks.Max(t2 => t2.Id) : 0; tasks.Add(new TaskItem { Id = maxId + 1, Title = title, Priority = priority, Created = DateTime.Now.ToString("yyyy-MM-dd HH:mm"), Done = false, }); SaveTasks(context, tasks); return ToolResult.Ok($"태스크 추가: #{maxId + 1} — {title} (우선순위: {priority})"); } private static ToolResult ListTasks(AgentContext context) { var tasks = LoadTasks(context); if (tasks.Count == 0) return ToolResult.Ok("등록된 태스크가 없습니다."); var sb = new StringBuilder(); sb.AppendLine($"태스크 목록 ({tasks.Count}개):"); foreach (var task in tasks.OrderBy(t => t.Done).ThenByDescending(t => t.Priority == "high" ? 0 : t.Priority == "medium" ? 1 : 2)) { var status = task.Done ? "✓" : "○"; sb.AppendLine($" {status} #{task.Id} [{task.Priority}] {task.Title} ({task.Created})"); } return ToolResult.Ok(sb.ToString()); } private static ToolResult MarkDone(JsonElement args, AgentContext context) { if (!args.TryGetProperty("id", out var idEl)) return ToolResult.Fail("'id'가 필요합니다."); var id = idEl.GetInt32(); var tasks = LoadTasks(context); var task = tasks.FirstOrDefault(t => t.Id == id); if (task == null) return ToolResult.Fail($"태스크 #{id}를 찾을 수 없습니다."); task.Done = true; SaveTasks(context, tasks); return ToolResult.Ok($"태스크 #{id} 완료: {task.Title}"); } private static List LoadTasks(AgentContext context) { var path = Path.Combine(context.WorkFolder, TaskFileName); if (!File.Exists(path)) return new List(); var json = TextFileCodec.ReadAllText(path).Text; return JsonSerializer.Deserialize>(json) ?? new List(); } private static void SaveTasks(AgentContext context, List tasks) { var path = Path.Combine(context.WorkFolder, TaskFileName); var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); var json = JsonSerializer.Serialize(tasks, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(path, json, TextFileCodec.Utf8NoBom); } private class TaskItem { [JsonPropertyName("id")] public int Id { get; set; } [JsonPropertyName("title")] public string Title { get; set; } = ""; [JsonPropertyName("priority")] public string Priority { get; set; } = "medium"; [JsonPropertyName("created")] public string Created { get; set; } = ""; [JsonPropertyName("done")] public bool Done { get; set; } } }