210 lines
8.0 KiB
C#
210 lines
8.0 KiB
C#
using System.IO;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>작업 폴더 내 TODO/태스크 추적 도구.</summary>
|
|
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<ToolResult> 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<string>(
|
|
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<TaskItem> LoadTasks(AgentContext context)
|
|
{
|
|
var path = Path.Combine(context.WorkFolder, TaskFileName);
|
|
if (!File.Exists(path)) return new List<TaskItem>();
|
|
var json = TextFileCodec.ReadAllText(path).Text;
|
|
return JsonSerializer.Deserialize<List<TaskItem>>(json) ?? new List<TaskItem>();
|
|
}
|
|
|
|
private static void SaveTasks(AgentContext context, List<TaskItem> 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; }
|
|
}
|
|
}
|