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; }
}
}