claw-code 동등 품질 4단계 연속 반영: Agentic 루프/상태복원/설정연동/릴리즈 게이트 정렬
Some checks failed
Release Gate / gate (push) Has been cancelled

- 도구 동등화: task/todo/tool-search + plan/worktree/team/cron 도구군 추가 및 ToolRegistry 등록\n- claw-code CamelCase 별칭 정규화 확장: EnterPlanMode/EnterWorktree/TeamCreate/CronCreate 등 -> 내부 snake_case 매핑\n- AgentLoop 런타임 강화: Code 탭 전용 도구 토글(CodeSettings) 반영, 비활성 도구 자동 차단\n- Worktree 상태 복원 연결: .ax/worktree_state.json 기반 루트 탐색/활성 worktree 복원 및 BuildContext 연동\n- 권한/플러그인 하드닝 기존 반영분 유지: target 기반 권한 판정 + internal 모드 플러그인 경로/manifest 검증\n- 설정 연동(UI): SettingsWindow Code 패널에 Plan/Worktree/Team/Cron 도구 on/off 토글 추가\n- 테스트 보강: AgentParityTools/AgentLoopE2E에 worktree 지속성, alias 정규화, 설정 차단 시나리오 추가\n- 검증 완료: dotnet build(경고0/오류0), ParityBenchmark 11/11, ReplayStability 12/12, 전체 371/371, release-gate 통과\n- 문서 동기화: AGENT_ROADMAP/NEXT_ROADMAP/CLAW_CODE_PARITY_PLAN 수치 및 기준 최신화
This commit is contained in:
2026-04-03 20:16:23 +09:00
parent 3b03b18f83
commit 2c047d062d
36 changed files with 1857 additions and 17 deletions

View File

@@ -94,7 +94,7 @@ public partial class AgentLoopService
{
EmitEvent(AgentEventType.Thinking, "", $"읽기 전용 도구 {calls.Count}개를 병렬 실행 중...");
var activeToolNames = _tools.GetActiveTools(llm.DisabledTools)
var activeToolNames = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides: null)
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)

View File

@@ -1536,7 +1536,8 @@ public partial class AgentLoopService
IEnumerable<string>? disabledToolNames,
SkillRuntimeOverrides? runtimeOverrides)
{
var active = _tools.GetActiveTools(disabledToolNames);
var mergedDisabled = MergeDisabledTools(disabledToolNames);
var active = _tools.GetActiveTools(mergedDisabled);
if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0)
return active;
@@ -1546,6 +1547,50 @@ public partial class AgentLoopService
.AsReadOnly();
}
private IEnumerable<string> MergeDisabledTools(IEnumerable<string>? disabledToolNames)
{
var disabled = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (disabledToolNames != null)
{
foreach (var name in disabledToolNames)
{
if (!string.IsNullOrWhiteSpace(name))
disabled.Add(name);
}
}
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return disabled;
var code = _settings.Settings.Llm.Code;
if (!code.EnablePlanModeTools)
{
disabled.Add("enter_plan_mode");
disabled.Add("exit_plan_mode");
}
if (!code.EnableWorktreeTools)
{
disabled.Add("enter_worktree");
disabled.Add("exit_worktree");
}
if (!code.EnableTeamTools)
{
disabled.Add("team_create");
disabled.Add("team_delete");
}
if (!code.EnableCronTools)
{
disabled.Add("cron_create");
disabled.Add("cron_delete");
disabled.Add("cron_list");
}
return disabled;
}
private static HashSet<string> ParseAllowedToolNames(string? raw)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -3510,6 +3555,25 @@ public partial class AgentLoopService
["task"] = "spawn_agent",
["sendmessage"] = "notify_tool",
["powershell"] = "process",
["toolsearch"] = "tool_search",
["todowrite"] = "todo_write",
["taskcreate"] = "task_create",
["taskget"] = "task_get",
["tasklist"] = "task_list",
["taskupdate"] = "task_update",
["taskstop"] = "task_stop",
["taskoutput"] = "task_output",
["enterplanmode"] = "enter_plan_mode",
["exitplanmode"] = "exit_plan_mode",
["enterworktree"] = "enter_worktree",
["exitworktree"] = "exit_worktree",
["teamcreate"] = "team_create",
["teamdelete"] = "team_delete",
["croncreate"] = "cron_create",
["crondelete"] = "cron_delete",
["cronlist"] = "cron_list",
["config"] = "project_rules",
["skill"] = "skill_manager",
};
private static string ResolveRequestedToolName(string requestedToolName, IReadOnlyCollection<string> activeToolNames)
@@ -3858,9 +3922,11 @@ public partial class AgentLoopService
private AgentContext BuildContext()
{
var llm = _settings.Settings.Llm;
var baseWorkFolder = llm.WorkFolder;
var runtimeWorkFolder = ResolveRuntimeWorkFolder(baseWorkFolder);
return new AgentContext
{
WorkFolder = llm.WorkFolder,
WorkFolder = runtimeWorkFolder,
Permission = llm.FilePermission,
BlockedPaths = llm.BlockedPaths,
BlockedExtensions = llm.BlockedExtensions,
@@ -3875,6 +3941,34 @@ public partial class AgentLoopService
};
}
private static string ResolveRuntimeWorkFolder(string? configuredRoot)
{
if (string.IsNullOrWhiteSpace(configuredRoot))
return "";
try
{
var root = Path.GetFullPath(configuredRoot);
if (!Directory.Exists(root))
return root;
var state = WorktreeStateStore.Load(root);
if (!string.IsNullOrWhiteSpace(state.Active))
{
var active = Path.GetFullPath(state.Active);
if (Directory.Exists(active)
&& active.StartsWith(root, StringComparison.OrdinalIgnoreCase))
return active;
}
return root;
}
catch
{
return configuredRoot ?? "";
}
}
private static string DescribeToolTarget(string toolName, JsonElement input, AgentContext context)
{
static string? TryReadString(JsonElement inputElement, params string[] names)
@@ -4049,7 +4143,7 @@ public partial class AgentLoopService
runId = _currentRunId,
tool = toolName,
target,
permission = context.GetEffectiveToolPermission(toolName)
permission = context.GetEffectiveToolPermission(toolName, target)
});
await RunPermissionLifecycleHooksAsync(
"__permission_request__",
@@ -4059,7 +4153,7 @@ public partial class AgentLoopService
messages,
success: true);
var effectivePerm = context.GetEffectiveToolPermission(toolName);
var effectivePerm = context.GetEffectiveToolPermission(toolName, target);
if (string.Equals(effectivePerm, "Ask", StringComparison.OrdinalIgnoreCase))
EmitEvent(AgentEventType.PermissionRequest, toolName, $"권한 확인 필요 · 대상: {target}");

View File

@@ -0,0 +1,38 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class CronCreateTool : IAgentTool
{
public string Name => "cron_create";
public string Description => "Create a local cron-like job descriptor.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["name"] = new() { Type = "string", Description = "Job name." },
["schedule"] = new() { Type = "string", Description = "Schedule expression (e.g. every 2h)." },
["command"] = new() { Type = "string", Description = "Command or task description." }
},
Required = ["name", "schedule", "command"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var name = args.TryGetProperty("name", out var n) ? (n.GetString() ?? "").Trim() : "";
var schedule = args.TryGetProperty("schedule", out var s) ? (s.GetString() ?? "").Trim() : "";
var command = args.TryGetProperty("command", out var c) ? (c.GetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(schedule) || string.IsNullOrWhiteSpace(command))
return Task.FromResult(ToolResult.Fail("name, schedule, command are required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var jobs = CronStore.Load(context.WorkFolder);
var job = new CronStore.CronJob { Name = name, Schedule = schedule, Command = command, Enabled = true };
jobs.Add(job);
CronStore.Save(context.WorkFolder, jobs);
return Task.FromResult(ToolResult.Ok($"Created cron job: {job.Name} ({job.Id})"));
}
}

View File

@@ -0,0 +1,38 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class CronDeleteTool : IAgentTool
{
public string Name => "cron_delete";
public string Description => "Delete a local cron-like job descriptor by id or name.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["id"] = new() { Type = "string", Description = "Job id." },
["name"] = new() { Type = "string", Description = "Job name (fallback)." }
},
Required = []
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = args.TryGetProperty("id", out var idEl) ? (idEl.GetString() ?? "").Trim() : "";
var name = args.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
return Task.FromResult(ToolResult.Fail("id or name is required."));
var jobs = CronStore.Load(context.WorkFolder);
var removed = jobs.RemoveAll(j =>
(!string.IsNullOrWhiteSpace(id) && string.Equals(j.Id, id, StringComparison.OrdinalIgnoreCase))
|| (!string.IsNullOrWhiteSpace(name) && string.Equals(j.Name, name, StringComparison.OrdinalIgnoreCase)));
CronStore.Save(context.WorkFolder, jobs);
return Task.FromResult(ToolResult.Ok(removed > 0 ? $"Deleted {removed} cron job(s)." : "No matching cron job found."));
}
}

View File

@@ -0,0 +1,36 @@
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class CronListTool : IAgentTool
{
public string Name => "cron_list";
public string Description => "List local cron-like jobs.";
public ToolParameterSchema Parameters => new()
{
Properties = new(),
Required = []
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var jobs = CronStore.Load(context.WorkFolder)
.OrderByDescending(j => j.CreatedAt)
.ToList();
if (jobs.Count == 0)
return Task.FromResult(ToolResult.Ok("No cron jobs."));
var sb = new StringBuilder();
sb.AppendLine($"cron jobs: {jobs.Count}");
foreach (var j in jobs)
sb.AppendLine($"- {j.Id} | {j.Name} | {j.Schedule} | enabled={j.Enabled} | {j.Command}");
return Task.FromResult(ToolResult.Ok(sb.ToString()));
}
}

View File

@@ -0,0 +1,58 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services.Agent;
internal static class CronStore
{
private const string CronRelativePath = ".ax/cron.json";
internal sealed class CronJob
{
[JsonPropertyName("id")]
public string Id { get; set; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("schedule")]
public string Schedule { get; set; } = "";
[JsonPropertyName("command")]
public string Command { get; set; } = "";
[JsonPropertyName("enabled")]
public bool Enabled { get; set; } = true;
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.Now;
}
internal static string GetPath(string root) => Path.Combine(root, CronRelativePath);
internal static List<CronJob> Load(string root)
{
var path = GetPath(root);
if (!File.Exists(path))
return [];
try
{
var json = TextFileCodec.ReadAllText(path).Text;
return JsonSerializer.Deserialize<List<CronJob>>(json) ?? [];
}
catch
{
return [];
}
}
internal static void Save(string root, List<CronJob> jobs)
{
var path = GetPath(root);
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(path, JsonSerializer.Serialize(jobs, new JsonSerializerOptions { WriteIndented = true }), TextFileCodec.Utf8NoBom);
}
}

View File

@@ -0,0 +1,28 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class EnterPlanModeTool : IAgentTool
{
public string Name => "enter_plan_mode";
public string Description => "Enable plan mode marker for current workspace.";
public ToolParameterSchema Parameters => new()
{
Properties = new(),
Required = []
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var path = Path.Combine(context.WorkFolder, ".ax", "plan_mode.state");
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(path, "on", TextFileCodec.Utf8NoBom);
return Task.FromResult(ToolResult.Ok("Plan mode enabled."));
}
}

View File

@@ -0,0 +1,48 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class EnterWorktreeTool : IAgentTool
{
public string Name => "enter_worktree";
public string Description => "Enter or create a nested worktree path inside current WorkFolder.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Relative worktree path." },
["create"] = new() { Type = "boolean", Description = "Create directory if missing (default true)." }
},
Required = ["path"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var relative = args.TryGetProperty("path", out var pathEl) ? (pathEl.GetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(relative))
return Task.FromResult(ToolResult.Fail("path is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var create = !args.TryGetProperty("create", out var createEl) || createEl.ValueKind != JsonValueKind.False;
var root = WorktreeStateStore.ResolveRoot(context.WorkFolder);
var full = Path.GetFullPath(Path.Combine(root, relative));
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase))
return Task.FromResult(ToolResult.Fail("worktree path must stay inside WorkFolder."));
if (!Directory.Exists(full))
{
if (!create)
return Task.FromResult(ToolResult.Fail("worktree path does not exist."));
Directory.CreateDirectory(full);
}
var state = WorktreeStateStore.Load(root);
state.Active = full;
WorktreeStateStore.Save(root, state);
context.WorkFolder = full;
return Task.FromResult(ToolResult.Ok($"Entered worktree: {full}"));
}
}

View File

@@ -0,0 +1,26 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class ExitPlanModeTool : IAgentTool
{
public string Name => "exit_plan_mode";
public string Description => "Disable plan mode marker for current workspace.";
public ToolParameterSchema Parameters => new()
{
Properties = new(),
Required = []
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var path = Path.Combine(context.WorkFolder, ".ax", "plan_mode.state");
if (File.Exists(path))
File.Delete(path);
return Task.FromResult(ToolResult.Ok("Plan mode disabled."));
}
}

View File

@@ -0,0 +1,29 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class ExitWorktreeTool : IAgentTool
{
public string Name => "exit_worktree";
public string Description => "Exit current worktree and return to root WorkFolder state.";
public ToolParameterSchema Parameters => new()
{
Properties = new(),
Required = []
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var root = WorktreeStateStore.ResolveRoot(context.WorkFolder);
var state = WorktreeStateStore.Load(root);
state.Active = root;
WorktreeStateStore.Save(root, state);
context.WorkFolder = root;
return Task.FromResult(ToolResult.Ok($"Exited worktree. Active root: {root}"));
}
}

View File

@@ -1,5 +1,6 @@
using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Text.Json.Serialization;
namespace AxCopilot.Services.Agent;
@@ -95,7 +96,7 @@ public class AgentContext
private readonly object _permissionLock = new();
private readonly HashSet<string> _approvedPermissionCache = new(StringComparer.OrdinalIgnoreCase);
/// <summary>작업 폴더 경로.</summary>
public string WorkFolder { get; init; } = "";
public string WorkFolder { get; set; } = "";
/// <summary>파일 접근 권한. Ask | Auto | Deny</summary>
public string Permission { get; init; } = "Ask";
@@ -186,8 +187,13 @@ public class AgentContext
return await CheckToolPermissionAsync(toolName, filePath);
}
public string GetEffectiveToolPermission(string toolName)
public string GetEffectiveToolPermission(string toolName) => GetEffectiveToolPermission(toolName, null);
public string GetEffectiveToolPermission(string toolName, string? target)
{
if (TryResolvePatternPermission(toolName, target, out var patternPermission))
return patternPermission;
if (ToolPermissions.TryGetValue(toolName, out var toolPerm) &&
!string.IsNullOrWhiteSpace(toolPerm))
return toolPerm;
@@ -207,7 +213,7 @@ public class AgentContext
&& AxCopilot.Services.OperationModePolicy.IsBlockedAgentToolInInternalMode(toolName, target))
return false;
var effectivePerm = GetEffectiveToolPermission(toolName);
var effectivePerm = GetEffectiveToolPermission(toolName, target);
if (string.Equals(effectivePerm, "Deny", StringComparison.OrdinalIgnoreCase)) return false;
if (string.Equals(effectivePerm, "Auto", StringComparison.OrdinalIgnoreCase)) return true;
if (AskPermission == null) return false;
@@ -228,6 +234,67 @@ public class AgentContext
}
return allowed;
}
private bool TryResolvePatternPermission(string toolName, string? target, out string permission)
{
permission = "";
if (ToolPermissions.Count == 0 || string.IsNullOrWhiteSpace(target))
return false;
var normalizedTool = toolName.Trim();
var normalizedTarget = target.Trim();
foreach (var kv in ToolPermissions)
{
if (TryParsePatternRule(kv.Key, out var ruleTool, out var rulePattern)
&& string.Equals(ruleTool, normalizedTool, StringComparison.OrdinalIgnoreCase)
&& WildcardMatch(normalizedTarget, rulePattern)
&& !string.IsNullOrWhiteSpace(kv.Value))
{
permission = kv.Value.Trim();
return true;
}
}
foreach (var kv in ToolPermissions)
{
if (TryParsePatternRule(kv.Key, out var ruleTool, out var rulePattern)
&& string.Equals(ruleTool, "*", StringComparison.Ordinal)
&& WildcardMatch(normalizedTarget, rulePattern)
&& !string.IsNullOrWhiteSpace(kv.Value))
{
permission = kv.Value.Trim();
return true;
}
}
return false;
}
private static bool TryParsePatternRule(string? key, out string ruleTool, out string rulePattern)
{
ruleTool = "";
rulePattern = "";
if (string.IsNullOrWhiteSpace(key))
return false;
var trimmed = key.Trim();
var at = trimmed.IndexOf('@');
if (at <= 0 || at == trimmed.Length - 1)
return false;
ruleTool = trimmed[..at].Trim();
rulePattern = trimmed[(at + 1)..].Trim();
return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern);
}
private static bool WildcardMatch(string input, string pattern)
{
var regex = "^" + Regex.Escape(pattern)
.Replace("\\*", ".*")
.Replace("\\?", ".") + "$";
return Regex.IsMatch(input, regex, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
}
/// <summary>에이전트 이벤트 (UI 표시용).</summary>

View File

@@ -462,6 +462,23 @@ public static class SkillService
["LSP"] = "lsp_code_intel",
["ListMcpResourcesTool"] = "mcp_list_resources",
["ReadMcpResourceTool"] = "mcp_read_resource",
["ToolSearch"] = "tool_search",
["TodoWrite"] = "todo_write",
["TaskCreate"] = "task_create",
["TaskGet"] = "task_get",
["TaskList"] = "task_list",
["TaskUpdate"] = "task_update",
["TaskStop"] = "task_stop",
["TaskOutput"] = "task_output",
["EnterPlanMode"] = "enter_plan_mode",
["ExitPlanMode"] = "exit_plan_mode",
["EnterWorktree"] = "enter_worktree",
["ExitWorktree"] = "exit_worktree",
["TeamCreate"] = "team_create",
["TeamDelete"] = "team_delete",
["CronCreate"] = "cron_create",
["CronDelete"] = "cron_delete",
["CronList"] = "cron_list",
["Agent"] = "spawn_agent",
["Task"] = "spawn_agent",
["SendMessage"] = "notify_tool",

View File

@@ -0,0 +1,81 @@
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services.Agent;
internal static class TaskBoardStore
{
private const string StoreRelativePath = ".ax/taskboard.json";
internal sealed class TaskItem
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; } = "";
[JsonPropertyName("description")]
public string Description { get; set; } = "";
[JsonPropertyName("status")]
public string Status { get; set; } = "open"; // open | in_progress | blocked | done | stopped
[JsonPropertyName("priority")]
public string Priority { get; set; } = "medium"; // high | medium | low
[JsonPropertyName("output")]
public string Output { get; set; } = "";
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.Now;
[JsonPropertyName("updatedAt")]
public DateTime UpdatedAt { get; set; } = DateTime.Now;
}
internal static string GetStorePath(string workFolder)
=> Path.Combine(workFolder, StoreRelativePath);
internal static List<TaskItem> Load(string workFolder)
{
var path = GetStorePath(workFolder);
if (!File.Exists(path))
return [];
try
{
var json = TextFileCodec.ReadAllText(path).Text;
return JsonSerializer.Deserialize<List<TaskItem>>(json) ?? [];
}
catch
{
return [];
}
}
internal static void Save(string workFolder, List<TaskItem> tasks)
{
var path = GetStorePath(workFolder);
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(tasks, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(path, json, TextFileCodec.Utf8NoBom);
}
internal static int NextId(List<TaskItem> tasks)
=> tasks.Count == 0 ? 1 : tasks.Max(t => t.Id) + 1;
internal static bool IsValidStatus(string value)
=> value is "open" or "in_progress" or "blocked" or "done" or "stopped";
internal static bool IsValidPriority(string value)
=> value is "high" or "medium" or "low";
}

View File

@@ -0,0 +1,53 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class TaskCreateTool : IAgentTool
{
public string Name => "task_create";
public string Description =>
"Create a tracked task in the workspace task board (.ax/taskboard.json).";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["title"] = new() { Type = "string", Description = "Task title" },
["description"] = new() { Type = "string", Description = "Task description (optional)" },
["priority"] = new() { Type = "string", Description = "high | medium | low", Enum = ["high", "medium", "low"] },
},
Required = ["title"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var title = args.TryGetProperty("title", out var titleEl) ? (titleEl.GetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(title))
return Task.FromResult(ToolResult.Fail("title is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var description = args.TryGetProperty("description", out var descEl) ? (descEl.GetString() ?? "").Trim() : "";
var priority = args.TryGetProperty("priority", out var priEl) ? (priEl.GetString() ?? "medium").Trim().ToLowerInvariant() : "medium";
if (!TaskBoardStore.IsValidPriority(priority))
priority = "medium";
var tasks = TaskBoardStore.Load(context.WorkFolder);
var id = TaskBoardStore.NextId(tasks);
tasks.Add(new TaskBoardStore.TaskItem
{
Id = id,
Title = title,
Description = description,
Priority = priority,
Status = "open",
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now
});
TaskBoardStore.Save(context.WorkFolder, tasks);
return Task.FromResult(ToolResult.Ok($"Created task #{id}: {title} [{priority}]"));
}
}

View File

@@ -0,0 +1,46 @@
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class TaskGetTool : IAgentTool
{
public string Name => "task_get";
public string Description => "Get a task by id from the workspace task board.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["id"] = new() { Type = "integer", Description = "Task id" }
},
Required = ["id"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("id", out var idEl))
return Task.FromResult(ToolResult.Fail("id is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = idEl.GetInt32();
var task = TaskBoardStore.Load(context.WorkFolder).FirstOrDefault(t => t.Id == id);
if (task == null)
return Task.FromResult(ToolResult.Fail($"Task #{id} not found."));
var sb = new StringBuilder();
sb.AppendLine($"#{task.Id} {task.Title}");
sb.AppendLine($"status: {task.Status}");
sb.AppendLine($"priority: {task.Priority}");
if (!string.IsNullOrWhiteSpace(task.Description))
sb.AppendLine($"description: {task.Description}");
if (!string.IsNullOrWhiteSpace(task.Output))
sb.AppendLine($"output: {task.Output}");
sb.AppendLine($"updatedAt: {task.UpdatedAt:yyyy-MM-dd HH:mm:ss}");
return Task.FromResult(ToolResult.Ok(sb.ToString()));
}
}

View File

@@ -0,0 +1,48 @@
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class TaskListTool : IAgentTool
{
public string Name => "task_list";
public string Description => "List tasks from the workspace task board.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["status"] = new() { Type = "string", Description = "Filter by status (optional)." }
},
Required = []
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var status = args.TryGetProperty("status", out var statusEl) ? (statusEl.GetString() ?? "").Trim().ToLowerInvariant() : "";
var tasks = TaskBoardStore.Load(context.WorkFolder);
if (!string.IsNullOrWhiteSpace(status))
tasks = tasks.Where(t => string.Equals(t.Status, status, StringComparison.OrdinalIgnoreCase)).ToList();
if (tasks.Count == 0)
return Task.FromResult(ToolResult.Ok("No tasks."));
var ordered = tasks
.OrderBy(t => t.Status == "done" || t.Status == "stopped" ? 1 : 0)
.ThenByDescending(t => t.Priority == "high" ? 3 : t.Priority == "medium" ? 2 : 1)
.ThenByDescending(t => t.UpdatedAt)
.ToList();
var sb = new StringBuilder();
sb.AppendLine($"tasks: {ordered.Count}");
foreach (var t in ordered)
sb.AppendLine($"#{t.Id} [{t.Status}] [{t.Priority}] {t.Title}");
return Task.FromResult(ToolResult.Ok(sb.ToString()));
}
}

View File

@@ -0,0 +1,52 @@
using System.IO;
using System.Linq;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class TaskOutputTool : IAgentTool
{
public string Name => "task_output";
public string Description =>
"Write task progress/output text. Useful for long-running or delegated task traces.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["id"] = new() { Type = "integer", Description = "Task id" },
["output"] = new() { Type = "string", Description = "Output text" },
["append"] = new() { Type = "boolean", Description = "Append output instead of replace (default: true)" }
},
Required = ["id", "output"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("id", out var idEl))
return Task.FromResult(ToolResult.Fail("id is required."));
if (!args.TryGetProperty("output", out var outEl))
return Task.FromResult(ToolResult.Fail("output is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = idEl.GetInt32();
var output = (outEl.GetString() ?? "").Trim();
if (string.IsNullOrWhiteSpace(output))
return Task.FromResult(ToolResult.Fail("output is empty."));
var append = !args.TryGetProperty("append", out var appendEl) || appendEl.ValueKind != JsonValueKind.False;
var tasks = TaskBoardStore.Load(context.WorkFolder);
var task = tasks.FirstOrDefault(t => t.Id == id);
if (task == null)
return Task.FromResult(ToolResult.Fail($"Task #{id} not found."));
task.Output = append && !string.IsNullOrWhiteSpace(task.Output)
? $"{task.Output}\n{output}"
: output;
task.UpdatedAt = DateTime.Now;
TaskBoardStore.Save(context.WorkFolder, tasks);
return Task.FromResult(ToolResult.Ok($"Updated output for task #{task.Id}."));
}
}

View File

@@ -0,0 +1,44 @@
using System.IO;
using System.Linq;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class TaskStopTool : IAgentTool
{
public string Name => "task_stop";
public string Description => "Stop a task (marks status=stopped) on the workspace task board.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["id"] = new() { Type = "integer", Description = "Task id" },
["reason"] = new() { Type = "string", Description = "Stop reason (optional)." }
},
Required = ["id"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("id", out var idEl))
return Task.FromResult(ToolResult.Fail("id is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = idEl.GetInt32();
var reason = args.TryGetProperty("reason", out var reasonEl) ? (reasonEl.GetString() ?? "").Trim() : "";
var tasks = TaskBoardStore.Load(context.WorkFolder);
var task = tasks.FirstOrDefault(t => t.Id == id);
if (task == null)
return Task.FromResult(ToolResult.Fail($"Task #{id} not found."));
task.Status = "stopped";
if (!string.IsNullOrWhiteSpace(reason))
task.Output = string.IsNullOrWhiteSpace(task.Output) ? $"stop reason: {reason}" : $"{task.Output}\nstop reason: {reason}";
task.UpdatedAt = DateTime.Now;
TaskBoardStore.Save(context.WorkFolder, tasks);
return Task.FromResult(ToolResult.Ok($"Stopped task #{task.Id}: {task.Title}"));
}
}

View File

@@ -0,0 +1,70 @@
using System.IO;
using System.Linq;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class TaskUpdateTool : IAgentTool
{
public string Name => "task_update";
public string Description =>
"Update task fields (status/title/description/priority) in the workspace task board.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["id"] = new() { Type = "integer", Description = "Task id" },
["status"] = new() { Type = "string", Description = "open | in_progress | blocked | done | stopped" },
["title"] = new() { Type = "string", Description = "New title (optional)" },
["description"] = new() { Type = "string", Description = "New description (optional)" },
["priority"] = new() { Type = "string", Description = "high | medium | low" },
},
Required = ["id"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("id", out var idEl))
return Task.FromResult(ToolResult.Fail("id is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = idEl.GetInt32();
var tasks = TaskBoardStore.Load(context.WorkFolder);
var task = tasks.FirstOrDefault(t => t.Id == id);
if (task == null)
return Task.FromResult(ToolResult.Fail($"Task #{id} not found."));
if (args.TryGetProperty("status", out var statusEl))
{
var status = (statusEl.GetString() ?? "").Trim().ToLowerInvariant();
if (!TaskBoardStore.IsValidStatus(status))
return Task.FromResult(ToolResult.Fail("Invalid status."));
task.Status = status;
}
if (args.TryGetProperty("title", out var titleEl))
{
var title = (titleEl.GetString() ?? "").Trim();
if (!string.IsNullOrWhiteSpace(title))
task.Title = title;
}
if (args.TryGetProperty("description", out var descEl))
task.Description = (descEl.GetString() ?? "").Trim();
if (args.TryGetProperty("priority", out var priEl))
{
var priority = (priEl.GetString() ?? "").Trim().ToLowerInvariant();
if (!TaskBoardStore.IsValidPriority(priority))
return Task.FromResult(ToolResult.Fail("Invalid priority."));
task.Priority = priority;
}
task.UpdatedAt = DateTime.Now;
TaskBoardStore.Save(context.WorkFolder, tasks);
return Task.FromResult(ToolResult.Ok($"Updated task #{task.Id}: [{task.Status}] {task.Title}"));
}
}

View File

@@ -0,0 +1,36 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class TeamCreateTool : IAgentTool
{
public string Name => "team_create";
public string Description => "Create a teammate descriptor in local team board.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["name"] = new() { Type = "string", Description = "Teammate name." },
["role"] = new() { Type = "string", Description = "Role (worker/explorer/reviewer...)." }
},
Required = ["name"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var name = args.TryGetProperty("name", out var n) ? (n.GetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(name))
return Task.FromResult(ToolResult.Fail("name is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var role = args.TryGetProperty("role", out var r) ? (r.GetString() ?? "worker").Trim() : "worker";
var members = TeamStore.Load(context.WorkFolder);
var member = new TeamStore.Member { Name = name, Role = role };
members.Add(member);
TeamStore.Save(context.WorkFolder, members);
return Task.FromResult(ToolResult.Ok($"Created team member: {member.Name} ({member.Role}) id={member.Id}"));
}
}

View File

@@ -0,0 +1,39 @@
using System.IO;
using System.Linq;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class TeamDeleteTool : IAgentTool
{
public string Name => "team_delete";
public string Description => "Delete a teammate by id or name from local team board.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["id"] = new() { Type = "string", Description = "Member id." },
["name"] = new() { Type = "string", Description = "Member name (fallback)." }
},
Required = []
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = args.TryGetProperty("id", out var idEl) ? (idEl.GetString() ?? "").Trim() : "";
var name = args.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
return Task.FromResult(ToolResult.Fail("id or name is required."));
var members = TeamStore.Load(context.WorkFolder);
var removed = members.RemoveAll(m =>
(!string.IsNullOrWhiteSpace(id) && string.Equals(m.Id, id, StringComparison.OrdinalIgnoreCase))
|| (!string.IsNullOrWhiteSpace(name) && string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase)));
TeamStore.Save(context.WorkFolder, members);
return Task.FromResult(ToolResult.Ok(removed > 0 ? $"Deleted {removed} team member(s)." : "No matching member found."));
}
}

View File

@@ -0,0 +1,52 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services.Agent;
internal static class TeamStore
{
private const string TeamRelativePath = ".ax/team.json";
internal sealed class Member
{
[JsonPropertyName("id")]
public string Id { get; set; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("role")]
public string Role { get; set; } = "worker";
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.Now;
}
internal static string GetPath(string root) => Path.Combine(root, TeamRelativePath);
internal static List<Member> Load(string root)
{
var path = GetPath(root);
if (!File.Exists(path))
return [];
try
{
var json = TextFileCodec.ReadAllText(path).Text;
return JsonSerializer.Deserialize<List<Member>>(json) ?? [];
}
catch
{
return [];
}
}
internal static void Save(string root, List<Member> members)
{
var path = GetPath(root);
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(path, JsonSerializer.Serialize(members, new JsonSerializerOptions { WriteIndented = true }), TextFileCodec.Utf8NoBom);
}
}

View File

@@ -0,0 +1,113 @@
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class TodoWriteTool : IAgentTool
{
public string Name => "todo_write";
public string Description =>
"Maintain a lightweight TODO markdown list (.ax/TODO.md). " +
"Actions: add, list, done.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "add | list | done",
Enum = ["add", "list", "done"]
},
["text"] = new() { Type = "string", Description = "Todo text (for add)." },
["index"] = new() { Type = "integer", Description = "Todo index from list (for done)." }
},
Required = ["action"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.TryGetProperty("action", out var actionEl) ? (actionEl.GetString() ?? "").Trim().ToLowerInvariant() : "";
if (string.IsNullOrWhiteSpace(action))
return Task.FromResult(ToolResult.Fail("action is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var todoPath = Path.Combine(context.WorkFolder, ".ax", "TODO.md");
return action switch
{
"add" => Task.FromResult(Add(todoPath, args)),
"list" => Task.FromResult(List(todoPath)),
"done" => Task.FromResult(Done(todoPath, args)),
_ => Task.FromResult(ToolResult.Fail("Unsupported action. Use add|list|done."))
};
}
private static ToolResult Add(string todoPath, JsonElement args)
{
var text = args.TryGetProperty("text", out var textEl) ? (textEl.GetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(text))
return ToolResult.Fail("text is required for add.");
var dir = Path.GetDirectoryName(todoPath);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
if (!File.Exists(todoPath))
File.WriteAllText(todoPath, "# TODO\n\n", TextFileCodec.Utf8NoBom);
File.AppendAllText(todoPath, $"- [ ] {text}\n", TextFileCodec.Utf8NoBom);
return ToolResult.Ok($"Added TODO: {text}", todoPath);
}
private static ToolResult List(string todoPath)
{
if (!File.Exists(todoPath))
return ToolResult.Ok("TODO list is empty.");
var lines = TextFileCodec.SplitLines(TextFileCodec.ReadAllText(todoPath).Text);
var todos = lines
.Where(l => l.TrimStart().StartsWith("- [", StringComparison.Ordinal))
.ToList();
if (todos.Count == 0)
return ToolResult.Ok("TODO list is empty.");
var sb = new StringBuilder();
for (var i = 0; i < todos.Count; i++)
sb.AppendLine($"[{i}] {todos[i].Trim()}");
return ToolResult.Ok(sb.ToString());
}
private static ToolResult Done(string todoPath, JsonElement args)
{
if (!File.Exists(todoPath))
return ToolResult.Fail("TODO file does not exist.");
if (!args.TryGetProperty("index", out var indexEl))
return ToolResult.Fail("index is required for done.");
var targetIndex = indexEl.GetInt32();
var lines = TextFileCodec.SplitLines(TextFileCodec.ReadAllText(todoPath).Text);
var todoLineIndexes = new List<int>();
for (var i = 0; i < lines.Length; i++)
{
if (lines[i].TrimStart().StartsWith("- [", StringComparison.Ordinal))
todoLineIndexes.Add(i);
}
if (targetIndex < 0 || targetIndex >= todoLineIndexes.Count)
return ToolResult.Fail("invalid index.");
var lineIndex = todoLineIndexes[targetIndex];
var current = lines[lineIndex];
if (current.Contains("- [x]", StringComparison.OrdinalIgnoreCase))
return ToolResult.Ok($"TODO[{targetIndex}] already done.");
lines[lineIndex] = current.Replace("- [ ]", "- [x]").Replace("- [X]", "- [x]");
File.WriteAllText(todoPath, string.Join(Environment.NewLine, lines), TextFileCodec.Utf8NoBom);
return ToolResult.Ok($"Marked TODO[{targetIndex}] as done.", todoPath);
}
}

View File

@@ -122,6 +122,7 @@ public class ToolRegistry : IDisposable
registry.Register(new WaitAgentsTool());
registry.Register(new CodeSearchTool());
registry.Register(new TestLoopTool());
registry.Register(new ToolSearchTool(() => registry.All));
// 코드 리뷰 + 프로젝트 규칙
registry.Register(new CodeReviewTool());
@@ -179,6 +180,22 @@ public class ToolRegistry : IDisposable
// 태스크 추적
registry.Register(new TaskTrackerTool());
registry.Register(new TodoWriteTool());
registry.Register(new TaskCreateTool());
registry.Register(new TaskGetTool());
registry.Register(new TaskListTool());
registry.Register(new TaskUpdateTool());
registry.Register(new TaskStopTool());
registry.Register(new TaskOutputTool());
registry.Register(new EnterPlanModeTool());
registry.Register(new ExitPlanModeTool());
registry.Register(new EnterWorktreeTool());
registry.Register(new ExitWorktreeTool());
registry.Register(new TeamCreateTool());
registry.Register(new TeamDeleteTool());
registry.Register(new CronCreateTool());
registry.Register(new CronDeleteTool());
registry.Register(new CronListTool());
// 워크플로우 도구
registry.Register(new SuggestActionsTool());

View File

@@ -0,0 +1,92 @@
using System.Linq;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public sealed class ToolSearchTool : IAgentTool
{
private readonly Func<IReadOnlyCollection<IAgentTool>> _toolProvider;
public ToolSearchTool(Func<IReadOnlyCollection<IAgentTool>> toolProvider)
{
_toolProvider = toolProvider;
}
public string Name => "tool_search";
public string Description =>
"Search available tools by name or description and return ranked matches.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["query"] = new() { Type = "string", Description = "Search query." },
["limit"] = new() { Type = "integer", Description = "Max results (default 10, max 30)." },
["include_description"] = new() { Type = "boolean", Description = "Include descriptions in output." }
},
Required = ["query"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var query = args.TryGetProperty("query", out var queryEl) ? (queryEl.GetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(query))
return Task.FromResult(ToolResult.Fail("query is required."));
var limit = args.TryGetProperty("limit", out var limitEl) ? Math.Clamp(limitEl.GetInt32(), 1, 30) : 10;
var includeDescription = args.TryGetProperty("include_description", out var descEl)
&& descEl.ValueKind == JsonValueKind.True;
var queryTokens = query.Split([' ', '-', '_', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => t.ToLowerInvariant())
.ToArray();
var ranked = _toolProvider()
.Select(t => new { Tool = t, Score = Score(t, query.ToLowerInvariant(), queryTokens) })
.Where(x => x.Score > 0)
.OrderByDescending(x => x.Score)
.ThenBy(x => x.Tool.Name, StringComparer.OrdinalIgnoreCase)
.Take(limit)
.ToList();
if (ranked.Count == 0)
return Task.FromResult(ToolResult.Ok("No matching tools."));
var sb = new StringBuilder();
sb.AppendLine($"tool matches: {ranked.Count}");
foreach (var item in ranked)
{
if (includeDescription)
sb.AppendLine($"- {item.Tool.Name}: {item.Tool.Description}");
else
sb.AppendLine($"- {item.Tool.Name}");
}
return Task.FromResult(ToolResult.Ok(sb.ToString()));
}
private static int Score(IAgentTool tool, string query, IReadOnlyCollection<string> queryTokens)
{
var name = tool.Name.ToLowerInvariant();
var desc = tool.Description.ToLowerInvariant();
var score = 0;
if (name == query)
score += 1000;
if (name.Contains(query, StringComparison.Ordinal))
score += 300;
if (desc.Contains(query, StringComparison.Ordinal))
score += 100;
foreach (var token in queryTokens)
{
if (name.Contains(token, StringComparison.Ordinal))
score += 120;
if (desc.Contains(token, StringComparison.Ordinal))
score += 35;
}
return score;
}
}

View File

@@ -0,0 +1,85 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services.Agent;
internal static class WorktreeStateStore
{
private const string StateRelativePath = ".ax/worktree_state.json";
internal sealed class WorktreeState
{
[JsonPropertyName("root")]
public string Root { get; set; } = "";
[JsonPropertyName("active")]
public string Active { get; set; } = "";
[JsonPropertyName("updatedAt")]
public DateTime UpdatedAt { get; set; } = DateTime.Now;
}
private static string GetPath(string root) => Path.Combine(root, StateRelativePath);
internal static string ResolveRoot(string current)
{
try
{
var dir = new DirectoryInfo(Path.GetFullPath(current));
while (dir != null)
{
var candidate = dir.FullName;
var statePath = GetPath(candidate);
if (File.Exists(statePath))
{
var state = Load(candidate);
if (!string.IsNullOrWhiteSpace(state.Root))
{
var resolvedRoot = Path.GetFullPath(state.Root);
if (Directory.Exists(resolvedRoot))
return resolvedRoot;
}
return candidate;
}
dir = dir.Parent;
}
}
catch
{
// ignore and fallback
}
return Path.GetFullPath(current);
}
internal static WorktreeState Load(string root)
{
var path = GetPath(root);
if (!File.Exists(path))
return new WorktreeState { Root = root, Active = root, UpdatedAt = DateTime.Now };
try
{
var json = TextFileCodec.ReadAllText(path).Text;
var state = JsonSerializer.Deserialize<WorktreeState>(json);
return state ?? new WorktreeState { Root = root, Active = root, UpdatedAt = DateTime.Now };
}
catch
{
return new WorktreeState { Root = root, Active = root, UpdatedAt = DateTime.Now };
}
}
internal static void Save(string root, WorktreeState state)
{
var path = GetPath(root);
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
state.Root = root;
state.UpdatedAt = DateTime.Now;
File.WriteAllText(path, JsonSerializer.Serialize(state, new JsonSerializerOptions { WriteIndented = true }), TextFileCodec.Utf8NoBom);
}
}