From 2c047d062d213cc80dcb31c007dcdf31e214f1b3 Mon Sep 17 00:00:00 2001 From: lacvet Date: Fri, 3 Apr 2026 20:16:23 +0900 Subject: [PATCH] =?UTF-8?q?claw-code=20=EB=8F=99=EB=93=B1=20=ED=92=88?= =?UTF-8?q?=EC=A7=88=204=EB=8B=A8=EA=B3=84=20=EC=97=B0=EC=86=8D=20?= =?UTF-8?q?=EB=B0=98=EC=98=81:=20Agentic=20=EB=A3=A8=ED=94=84/=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=B3=B5=EC=9B=90/=EC=84=A4=EC=A0=95=EC=97=B0?= =?UTF-8?q?=EB=8F=99/=EB=A6=B4=EB=A6=AC=EC=A6=88=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도구 동등화: 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 수치 및 기준 최신화 --- AGENTS.md | 1 + docs/AGENT_ROADMAP.md | 4 +- docs/CLAW_CODE_PARITY_PLAN.md | 11 +- docs/NEXT_ROADMAP.md | 4 +- .../Services/AgentLoopE2ETests.cs | 164 +++++++++++++++++ .../Services/AgentParityToolsTests.cs | 167 ++++++++++++++++++ .../Services/OperationModePolicyTests.cs | 43 +++++ src/AxCopilot/Core/PluginHost.cs | 98 ++++++++++ src/AxCopilot/Models/AppSettings.cs | 16 ++ .../Agent/AgentLoopParallelExecution.cs | 2 +- .../Services/Agent/AgentLoopService.cs | 102 ++++++++++- .../Services/Agent/CronCreateTool.cs | 38 ++++ .../Services/Agent/CronDeleteTool.cs | 38 ++++ src/AxCopilot/Services/Agent/CronListTool.cs | 36 ++++ src/AxCopilot/Services/Agent/CronStore.cs | 58 ++++++ .../Services/Agent/EnterPlanModeTool.cs | 28 +++ .../Services/Agent/EnterWorktreeTool.cs | 48 +++++ .../Services/Agent/ExitPlanModeTool.cs | 26 +++ .../Services/Agent/ExitWorktreeTool.cs | 29 +++ src/AxCopilot/Services/Agent/IAgentTool.cs | 73 +++++++- src/AxCopilot/Services/Agent/SkillService.cs | 17 ++ .../Services/Agent/TaskBoardStore.cs | 81 +++++++++ .../Services/Agent/TaskCreateTool.cs | 53 ++++++ src/AxCopilot/Services/Agent/TaskGetTool.cs | 46 +++++ src/AxCopilot/Services/Agent/TaskListTool.cs | 48 +++++ .../Services/Agent/TaskOutputTool.cs | 52 ++++++ src/AxCopilot/Services/Agent/TaskStopTool.cs | 44 +++++ .../Services/Agent/TaskUpdateTool.cs | 70 ++++++++ .../Services/Agent/TeamCreateTool.cs | 36 ++++ .../Services/Agent/TeamDeleteTool.cs | 39 ++++ src/AxCopilot/Services/Agent/TeamStore.cs | 52 ++++++ src/AxCopilot/Services/Agent/TodoWriteTool.cs | 113 ++++++++++++ src/AxCopilot/Services/Agent/ToolRegistry.cs | 17 ++ .../Services/Agent/ToolSearchTool.cs | 92 ++++++++++ .../Services/Agent/WorktreeStateStore.cs | 85 +++++++++ src/AxCopilot/Views/SettingsWindow.xaml | 43 +++++ 36 files changed, 1857 insertions(+), 17 deletions(-) create mode 100644 src/AxCopilot.Tests/Services/AgentParityToolsTests.cs create mode 100644 src/AxCopilot/Services/Agent/CronCreateTool.cs create mode 100644 src/AxCopilot/Services/Agent/CronDeleteTool.cs create mode 100644 src/AxCopilot/Services/Agent/CronListTool.cs create mode 100644 src/AxCopilot/Services/Agent/CronStore.cs create mode 100644 src/AxCopilot/Services/Agent/EnterPlanModeTool.cs create mode 100644 src/AxCopilot/Services/Agent/EnterWorktreeTool.cs create mode 100644 src/AxCopilot/Services/Agent/ExitPlanModeTool.cs create mode 100644 src/AxCopilot/Services/Agent/ExitWorktreeTool.cs create mode 100644 src/AxCopilot/Services/Agent/TaskBoardStore.cs create mode 100644 src/AxCopilot/Services/Agent/TaskCreateTool.cs create mode 100644 src/AxCopilot/Services/Agent/TaskGetTool.cs create mode 100644 src/AxCopilot/Services/Agent/TaskListTool.cs create mode 100644 src/AxCopilot/Services/Agent/TaskOutputTool.cs create mode 100644 src/AxCopilot/Services/Agent/TaskStopTool.cs create mode 100644 src/AxCopilot/Services/Agent/TaskUpdateTool.cs create mode 100644 src/AxCopilot/Services/Agent/TeamCreateTool.cs create mode 100644 src/AxCopilot/Services/Agent/TeamDeleteTool.cs create mode 100644 src/AxCopilot/Services/Agent/TeamStore.cs create mode 100644 src/AxCopilot/Services/Agent/TodoWriteTool.cs create mode 100644 src/AxCopilot/Services/Agent/ToolSearchTool.cs create mode 100644 src/AxCopilot/Services/Agent/WorktreeStateStore.cs diff --git a/AGENTS.md b/AGENTS.md index aa5520a..da5adac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ - 기능/로직 구현은 `claude-code` 실제 코드 흐름을 우선 참고하여 동등 품질 기준으로 반영합니다. (실제 폴더: `claw-code`) - `claude-code`와 추후 비교/대조 시 문제 없도록 **기본 로직(동작 순서·예외 처리·검증 흐름)은 유지**하되, 코드 표현(변수명·함수 분해·구조화)은 가독성과 제품 코드 규칙에 맞게 변경하여 반영합니다. - 작업 완료 후에는 변경사항을 점검하고 **반드시 Git push까지 진행**합니다. +- Git 커밋/푸시 시 커밋 메시지는 **반드시 한국어로 작성**하며, 변경 목적·핵심 수정사항·검증 결과가 드러나도록 **상세하게** 작성합니다. - 작업 중 오류가 발생해 복구가 되지 않으면, **이전 정상 버전을 다시 받아 기준 상태에서 작업을 재개**합니다. ### 개발 계획 수립 기준 (필수) diff --git a/docs/AGENT_ROADMAP.md b/docs/AGENT_ROADMAP.md index 4f75391..5ef3f8f 100644 --- a/docs/AGENT_ROADMAP.md +++ b/docs/AGENT_ROADMAP.md @@ -34,9 +34,9 @@ 3. 패리티 수치(테스트 통과 수/게이트 상태)를 로드맵 문서 간 동일 문구로 유지. ## 6. 최신 검증 스냅샷 (2026-04-03) -- `dotnet test --filter "Suite=ParityBenchmark"`: 7/7 통과. +- `dotnet test --filter "Suite=ParityBenchmark"`: 11/11 통과. - `dotnet test --filter "Suite=ReplayStability"`: 12/12 통과. -- `dotnet test`: 361/361 통과. +- `dotnet test`: 371/371 통과. ## 7. 권한 Hook 계약 (P2 마감 기준) - lifecycle hook 키: diff --git a/docs/CLAW_CODE_PARITY_PLAN.md b/docs/CLAW_CODE_PARITY_PLAN.md index 750b29d..a3bd9c9 100644 --- a/docs/CLAW_CODE_PARITY_PLAN.md +++ b/docs/CLAW_CODE_PARITY_PLAN.md @@ -40,7 +40,7 @@ ## 6. 2026-04-03 점검 스냅샷 - 기준 시점: 2026-04-03. - 계획 대비 현재 수준: 약 92~95%. -- 테스트 상태: `dotnet test` 361/361 통과. +- 테스트 상태: `dotnet test` 371/371 통과. - P1 Hook 계약: 구현 완료 수준. - P2 세션/이벤트 내구성: 구현 완료 수준(복원/재생 경계 케이스 테스트 반영). - P3 실패 복구 표준화: 구현 완료 수준(unknown-tool/권한/정체/fork 강제 흐름 반영). @@ -55,7 +55,7 @@ - 레거시 도구명 `process_run` 참조: 0건 (`process`로 정규화). - 레거시 도구명 `grep_tool` 참조: 0건 (`grep`로 정규화). - 내부 모드 차단 정책: `http_tool` 전면 차단, `open_external`의 외부 URL 차단. -- 테스트 상태: `dotnet test` 361/361 통과. +- 테스트 상태: `dotnet test` 371/371 통과. ## 8. claw-code 소스 직접 비교 결과 (2026-04-03) - 비교 기준 소스: `claw-code/.../src/tools.ts`, `src/Tool.ts`, `src/skills/loadSkillsDir.ts`, `src/skills/bundled/*.ts`. @@ -101,16 +101,17 @@ | Hook 입력 변형 반영 | `AgentLoopE2ETests.RunAsync_PreHookInputMutation_ChangesToolArguments` | pre-hook `updatedInput`이 실제 도구 입력에 적용됨 | | Runtime 정책(`allowed_tools`) 강제 | `AgentLoopE2ETests.RunAsync_DisallowedTool_ByRuntimePolicy_EmitsPolicyRecoveryError` | 비허용 도구 차단 + 정책 복구 경고 후 종료 | | Hook filter 정합성 | `AgentLoopE2ETests.RunAsync_HookFilters_ExecuteOnlyMatchingHookForToolAndTiming` | 지정된 hook만 실행되고 비매칭 hook는 미실행 | +| claw-code alias(`EnterPlanMode`) 정규화 | `AgentLoopE2ETests.RunAsync_EnterPlanModeAlias_ResolvesAndExecutes` | CamelCase 도구명이 AX 내부 snake_case 도구로 매핑되어 정상 실행 | ### 벤치마크 배포 체크리스트 연결 1. `dotnet build` 경고 0/오류 0. -2. `dotnet test` 전체 통과 (`361/361` 기준, 증가 시 최신 값으로 동기화). -3. 위 7개 시나리오의 회귀 테스트가 모두 통과. +2. `dotnet test` 전체 통과 (`371/371` 기준, 증가 시 최신 값으로 동기화). +3. 위 8개 시나리오의 회귀 테스트가 모두 통과. 4. 패리티 수치/상태를 `NEXT_ROADMAP.md`와 동일 문구로 동기화. 5. 릴리즈 전 게이트 스크립트 실행: `powershell -ExecutionPolicy Bypass -File .\scripts\release-gate.ps1` ### 실행 증적 (2026-04-03) -- `dotnet test --filter "Suite=ParityBenchmark"`: 7/7 통과. +- `dotnet test --filter "Suite=ParityBenchmark"`: 11/11 통과. - `powershell -ExecutionPolicy Bypass -File .\scripts\release-gate.ps1`: build/replay/full gate 통과. ## 13. 세션 Replay 안정성 기준 (고정) diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md index 71a63c3..f470368 100644 --- a/docs/NEXT_ROADMAP.md +++ b/docs/NEXT_ROADMAP.md @@ -36,7 +36,7 @@ ## 7. 2026-04-03 실행 증적 동기화 (M4 포함) - 기준 시점: 2026-04-03. -- 테스트: `dotnet test` 361/361 통과. +- 테스트: `dotnet test` 371/371 통과. - M1 증적: Hook 계약 필드(`updatedInput`, `updatedPermissions`, `additionalContext`) 반영 경로 구현 완료. - M2 증적: run 복원/이력 재구성(`RestoreRecentFromExecutionEvents`, `RestoreCurrentAgentRun`, plan 이력 조회) 구현 및 테스트 존재. - M3 증적: unknown-tool 복구 루프/결정 이벤트 처리 경로 구현 및 테스트 존재. @@ -56,7 +56,7 @@ - 기준 문서: `docs/CLAW_CODE_PARITY_PLAN.md` 13절. - 테스트 태그: `Suite=ReplayStability`. - 운영 기준: 릴리즈 전 `ReplayStability` 시나리오 전건 통과 시 replay 불일치 0건으로 판정. -- 최신 실행 증적(2026-04-03): `ParityBenchmark 7/7`, `ReplayStability 12/12`, 전체 `361/361`. +- 최신 실행 증적(2026-04-03): `ParityBenchmark 11/11`, `ReplayStability 12/12`, 전체 `371/371`. - 실행 자동화: `scripts/release-gate.ps1`로 빌드/벤치마크/리플레이/전체 테스트를 일괄 점검. ## 11. 권한 Hook 계약 고정 (M1 완료 기준) diff --git a/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs b/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs index 75f7c88..d138168 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs @@ -309,6 +309,170 @@ public class AgentLoopE2ETests } } + [Fact] + public async Task RunAsync_TaskCreateAlias_ResolvesAndExecutes() + { + var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-task-alias-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + using var server = new FakeOllamaServer( + [ + BuildToolCallResponse("TaskCreate", new { title = "Alias task", priority = "high" }, "task alias"), + BuildTextResponse("완료"), + ]); + + var settings = BuildLoopSettings(server.Endpoint); + settings.Settings.Llm.WorkFolder = tempDir; + using var llm = new LlmService(settings); + using var tools = ToolRegistry.CreateDefault(); + var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" }; + + var events = new List(); + loop.EventOccurred += evt => events.Add(evt); + + var result = await loop.RunAsync( + [ + new ChatMessage { Role = "user", Content = "task create alias" } + ]); + + result.Should().Contain("완료"); + events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "task_create"); + events.Should().Contain(e => e.Type == AgentEventType.ToolResult && e.ToolName == "task_create" && e.Success); + } + finally + { + try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public async Task RunAsync_EnterPlanModeAlias_ResolvesAndExecutes() + { + var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-plan-alias-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + using var server = new FakeOllamaServer( + [ + BuildToolCallResponse("EnterPlanMode", new { }, "plan alias"), + BuildTextResponse("?꾨즺"), + ]); + + var settings = BuildLoopSettings(server.Endpoint); + settings.Settings.Llm.WorkFolder = tempDir; + using var llm = new LlmService(settings); + using var tools = ToolRegistry.CreateDefault(); + var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" }; + + var events = new List(); + loop.EventOccurred += evt => events.Add(evt); + + var result = await loop.RunAsync( + [ + new ChatMessage { Role = "user", Content = "enter plan mode alias" } + ]); + + result.Should().Contain("?꾨즺"); + events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "enter_plan_mode"); + events.Should().Contain(e => e.Type == AgentEventType.ToolResult && e.ToolName == "enter_plan_mode" && e.Success); + } + finally + { + try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public async Task RunAsync_WorktreeState_PersistsAcrossRuns() + { + var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-worktree-persist-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + using (var enterServer = new FakeOllamaServer( + [ + BuildToolCallResponse("EnterWorktree", new { path = "wt1", create = true }, "enter worktree"), + BuildTextResponse("done-1"), + ])) + { + var enterSettings = BuildLoopSettings(enterServer.Endpoint); + enterSettings.Settings.Llm.WorkFolder = tempDir; + enterSettings.Settings.Llm.FilePermission = "Auto"; + using var enterLlm = new LlmService(enterSettings); + using var enterTools = ToolRegistry.CreateDefault(); + var enterLoop = new AgentLoopService(enterLlm, enterTools, enterSettings) { ActiveTab = "Code" }; + var first = await enterLoop.RunAsync( + [ + new ChatMessage { Role = "user", Content = "enter worktree now" } + ]); + first.Should().Contain("done-1"); + } + + using (var writeServer = new FakeOllamaServer( + [ + BuildToolCallResponse("TaskCreate", new { title = "persisted-task", priority = "high" }, "create task in persisted worktree"), + BuildTextResponse("done-2"), + ])) + { + var writeSettings = BuildLoopSettings(writeServer.Endpoint); + writeSettings.Settings.Llm.WorkFolder = tempDir; + writeSettings.Settings.Llm.FilePermission = "Auto"; + using var writeLlm = new LlmService(writeSettings); + using var writeTools = ToolRegistry.CreateDefault(); + var writeLoop = new AgentLoopService(writeLlm, writeTools, writeSettings) { ActiveTab = "Code" }; + var second = await writeLoop.RunAsync( + [ + new ChatMessage { Role = "user", Content = "write file in active worktree" } + ]); + second.Should().Contain("done-2"); + } + + File.Exists(Path.Combine(tempDir, "wt1", ".ax", "taskboard.json")).Should().BeTrue(); + } + finally + { + try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public async Task RunAsync_CodeSetting_DisablesPlanModeTools() + { + var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-disable-plan-tool-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + using var server = new FakeOllamaServer( + [ + BuildToolCallResponse("EnterPlanMode", new { }, "attempt disabled tool"), + BuildTextResponse("done"), + ]); + + var settings = BuildLoopSettings(server.Endpoint); + settings.Settings.Llm.WorkFolder = tempDir; + settings.Settings.Llm.Code.EnablePlanModeTools = false; + using var llm = new LlmService(settings); + using var tools = ToolRegistry.CreateDefault(); + var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" }; + + var events = new List(); + loop.EventOccurred += evt => events.Add(evt); + + var result = await loop.RunAsync( + [ + new ChatMessage { Role = "user", Content = "run plan mode tool" } + ]); + + result.Should().Contain("done"); + events.Should().Contain(e => e.Type == AgentEventType.Error && e.ToolName == "EnterPlanMode"); + } + finally + { + try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } + } + } + private static SettingsService BuildLoopSettings(string endpoint) { var settings = new SettingsService(); diff --git a/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs b/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs new file mode 100644 index 0000000..59c0eee --- /dev/null +++ b/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs @@ -0,0 +1,167 @@ +using System.IO; +using System.Text.Json; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class AgentParityToolsTests +{ + [Fact] + public async Task TaskBoardTools_CreateUpdateOutputAndGet_ShouldRoundTrip() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-task-board-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + try + { + var context = new AgentContext + { + WorkFolder = workDir, + Permission = "Auto", + OperationMode = "external", + }; + + var create = new TaskCreateTool(); + var update = new TaskUpdateTool(); + var output = new TaskOutputTool(); + var get = new TaskGetTool(); + + var created = await create.ExecuteAsync(JsonDocument.Parse("""{"title":"Implement parity","priority":"high"}""").RootElement, context); + created.Success.Should().BeTrue(); + created.Output.Should().Contain("#1"); + + var updated = await update.ExecuteAsync(JsonDocument.Parse("""{"id":1,"status":"in_progress"}""").RootElement, context); + updated.Success.Should().BeTrue(); + updated.Output.Should().Contain("in_progress"); + + var outResult = await output.ExecuteAsync(JsonDocument.Parse("""{"id":1,"output":"step-1 done","append":true}""").RootElement, context); + outResult.Success.Should().BeTrue(); + + var loaded = await get.ExecuteAsync(JsonDocument.Parse("""{"id":1}""").RootElement, context); + loaded.Success.Should().BeTrue(); + loaded.Output.Should().Contain("Implement parity"); + loaded.Output.Should().Contain("step-1 done"); + } + finally + { + try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { } + } + } + + [Fact] + public async Task TodoWriteTool_AddListDone_ShouldTrackMarkdownChecklist() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-todo-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + try + { + var context = new AgentContext + { + WorkFolder = workDir, + Permission = "Auto", + OperationMode = "external", + }; + + var tool = new TodoWriteTool(); + (await tool.ExecuteAsync(JsonDocument.Parse("""{"action":"add","text":"Check permissions"}""").RootElement, context)).Success.Should().BeTrue(); + var list = await tool.ExecuteAsync(JsonDocument.Parse("""{"action":"list"}""").RootElement, context); + list.Success.Should().BeTrue(); + list.Output.Should().Contain("Check permissions"); + + var done = await tool.ExecuteAsync(JsonDocument.Parse("""{"action":"done","index":0}""").RootElement, context); + done.Success.Should().BeTrue(); + } + finally + { + try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { } + } + } + + [Fact] + public async Task PlanModeTools_EnterThenExit_ShouldToggleStateFile() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-plan-mode-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + try + { + var context = new AgentContext + { + WorkFolder = workDir, + Permission = "Auto", + OperationMode = "external", + }; + + var enter = new EnterPlanModeTool(); + var exit = new ExitPlanModeTool(); + var statePath = Path.Combine(workDir, ".ax", "plan_mode.state"); + + (await enter.ExecuteAsync(JsonDocument.Parse("""{}""").RootElement, context)).Success.Should().BeTrue(); + File.Exists(statePath).Should().BeTrue(); + + (await exit.ExecuteAsync(JsonDocument.Parse("""{}""").RootElement, context)).Success.Should().BeTrue(); + File.Exists(statePath).Should().BeFalse(); + } + finally + { + try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { } + } + } + + [Fact] + public async Task WorktreeTeamCronTools_ShouldPersistState() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-worktree-team-cron-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + try + { + var context = new AgentContext + { + WorkFolder = workDir, + Permission = "Auto", + OperationMode = "external", + }; + + var enterWorktree = new EnterWorktreeTool(); + var exitWorktree = new ExitWorktreeTool(); + var entered = await enterWorktree.ExecuteAsync(JsonDocument.Parse("""{"path":"feature/a","create":true}""").RootElement, context); + entered.Success.Should().BeTrue(); + File.Exists(Path.Combine(workDir, ".ax", "worktree_state.json")).Should().BeTrue(); + context.WorkFolder.Replace('\\', '/').Should().EndWith("/feature/a"); + + var write = new FileWriteTool(); + var writeInWorktree = await write.ExecuteAsync( + JsonDocument.Parse("""{"path":"notes.txt","content":"worktree-content"}""").RootElement, + context, + CancellationToken.None); + writeInWorktree.Success.Should().BeTrue(); + File.Exists(Path.Combine(workDir, "feature", "a", "notes.txt")).Should().BeTrue(); + + var exited = await exitWorktree.ExecuteAsync(JsonDocument.Parse("""{}""").RootElement, context); + exited.Success.Should().BeTrue(); + Path.GetFullPath(context.WorkFolder).Should().Be(Path.GetFullPath(workDir)); + + var teamCreate = new TeamCreateTool(); + var teamDelete = new TeamDeleteTool(); + (await teamCreate.ExecuteAsync(JsonDocument.Parse("""{"name":"worker-1","role":"worker"}""").RootElement, context)).Success.Should().BeTrue(); + var teamFile = Path.Combine(workDir, ".ax", "team.json"); + File.Exists(teamFile).Should().BeTrue(); + var teamJson = await File.ReadAllTextAsync(teamFile); + teamJson.Should().Contain("worker-1"); + (await teamDelete.ExecuteAsync(JsonDocument.Parse("""{"name":"worker-1"}""").RootElement, context)).Success.Should().BeTrue(); + + var cronCreate = new CronCreateTool(); + var cronList = new CronListTool(); + var cronDelete = new CronDeleteTool(); + (await cronCreate.ExecuteAsync(JsonDocument.Parse("""{"name":"nightly","schedule":"daily 02:00","command":"build"}""").RootElement, context)).Success.Should().BeTrue(); + var listed = await cronList.ExecuteAsync(JsonDocument.Parse("""{}""").RootElement, context); + listed.Success.Should().BeTrue(); + listed.Output.Should().Contain("nightly"); + (await cronDelete.ExecuteAsync(JsonDocument.Parse("""{"name":"nightly"}""").RootElement, context)).Success.Should().BeTrue(); + } + finally + { + try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { } + } + } +} diff --git a/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs b/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs index daa891a..36a294d 100644 --- a/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs +++ b/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs @@ -59,4 +59,47 @@ public class OperationModePolicyTests var allowed = await context.CheckToolPermissionAsync("http_tool", "https://example.com"); allowed.Should().BeTrue(); } + + [Fact] + public void AgentContext_GetEffectiveToolPermission_PrefersPatternRule() + { + var context = new AgentContext + { + Permission = "Ask", + ToolPermissions = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["process"] = "deny", + ["process@git *"] = "auto", + ["*@*.md"] = "ask", + } + }; + + context.GetEffectiveToolPermission("process", "git status").Should().Be("auto"); + context.GetEffectiveToolPermission("process", "powershell -NoProfile").Should().Be("deny"); + context.GetEffectiveToolPermission("file_read", @"E:\work\README.md").Should().Be("ask"); + } + + [Fact] + public async Task AgentContext_CheckToolPermissionAsync_UsesPatternRuleWithoutPrompt() + { + var askCalled = false; + var context = new AgentContext + { + OperationMode = OperationModePolicy.ExternalMode, + Permission = "Ask", + ToolPermissions = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["process@git *"] = "auto", + }, + AskPermission = (_, _) => + { + askCalled = true; + return Task.FromResult(false); + } + }; + + var allowed = await context.CheckToolPermissionAsync("process", "git status"); + allowed.Should().BeTrue(); + askCalled.Should().BeFalse(); + } } diff --git a/src/AxCopilot/Core/PluginHost.cs b/src/AxCopilot/Core/PluginHost.cs index cc0f8ef..5d8f1b9 100644 --- a/src/AxCopilot/Core/PluginHost.cs +++ b/src/AxCopilot/Core/PluginHost.cs @@ -1,6 +1,7 @@ using System.IO; using System.IO.Compression; using System.Reflection; +using System.Text.Json; using AxCopilot.Handlers; using AxCopilot.SDK; using AxCopilot.Services; @@ -15,6 +16,9 @@ public class PluginHost private readonly SettingsService _settings; private readonly CommandResolver _resolver; private readonly List _loadedPlugins = new(); + private readonly string _pluginsRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "plugins"); public IReadOnlyList LoadedPlugins => _loadedPlugins; @@ -58,6 +62,12 @@ public class PluginHost return; } + if (!IsPluginPathAllowed(dllPath)) + { + LogService.Warn($"플러그인 경로 정책 차단: {dllPath}"); + return; + } + try { var assembly = Assembly.LoadFrom(dllPath); @@ -152,6 +162,14 @@ public class PluginHost entry.ExtractToFile(destPath, overwrite: true); } + if (OperationModePolicy.IsInternal(_settings.Settings) && + !TryValidatePluginManifest(targetDir, out var validationError)) + { + try { Directory.Delete(targetDir, recursive: true); } catch { } + LogService.Warn($"내부 모드 플러그인 정책 차단: {validationError}"); + return 0; + } + // .dll 파일 찾아서 플러그인으로 등록 foreach (var dllFile in Directory.EnumerateFiles(targetDir, "*.dll")) { @@ -235,4 +253,84 @@ public class PluginHost return false; } } + + private bool IsPluginPathAllowed(string dllPath) + { + if (!OperationModePolicy.IsInternal(_settings.Settings)) + return true; + + // internal 모드에서는 로컬 plugin root 하위 DLL만 허용 + return IsPathUnderRoot(dllPath, _pluginsRoot); + } + + private static bool IsPathUnderRoot(string path, string root) + { + try + { + var fullPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || string.Equals(fullPath, fullRoot, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static bool TryValidatePluginManifest(string pluginDir, out string error) + { + error = ""; + var manifestPath = Path.Combine(pluginDir, ".claude-plugin", "plugin.json"); + if (!File.Exists(manifestPath)) + { + error = "plugin.json(.claude-plugin) 파일이 없습니다."; + return false; + } + + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath)); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + error = "plugin.json 형식이 올바르지 않습니다."; + return false; + } + + var root = doc.RootElement; + var name = root.TryGetProperty("name", out var n) ? n.GetString() : null; + var version = root.TryGetProperty("version", out var v) ? v.GetString() : null; + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(version)) + { + error = "plugin.json의 name/version이 필요합니다."; + return false; + } + + if (root.TryGetProperty("hooks", out var hooksProp) && hooksProp.ValueKind == JsonValueKind.String) + { + var hookRel = hooksProp.GetString() ?? ""; + if (!string.IsNullOrWhiteSpace(hookRel)) + { + var fullHookPath = Path.GetFullPath(Path.Combine(pluginDir, hookRel)); + if (!IsPathUnderRoot(fullHookPath, pluginDir)) + { + error = "hooks 경로가 plugin 디렉터리 밖을 가리킵니다."; + return false; + } + if (!File.Exists(fullHookPath)) + { + error = $"hooks 파일이 없습니다: {hookRel}"; + return false; + } + } + } + + return true; + } + catch (Exception ex) + { + error = $"plugin.json 파싱 실패: {ex.Message}"; + return false; + } + } } diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index d2b6fbe..9a6867f 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -1023,6 +1023,22 @@ public class CodeSettings /// [JsonPropertyName("enableCodeVerification")] public bool EnableCodeVerification { get; set; } = false; + + /// Code 탭에서 Plan Mode 도구(enter/exit plan mode) 사용 여부. 기본 true. + [JsonPropertyName("enablePlanModeTools")] + public bool EnablePlanModeTools { get; set; } = true; + + /// Code 탭에서 Worktree 도구(enter/exit worktree) 사용 여부. 기본 true. + [JsonPropertyName("enableWorktreeTools")] + public bool EnableWorktreeTools { get; set; } = true; + + /// Code 탭에서 Team 도구(team create/delete) 사용 여부. 기본 true. + [JsonPropertyName("enableTeamTools")] + public bool EnableTeamTools { get; set; } = true; + + /// Code 탭에서 Cron 도구(cron create/list/delete) 사용 여부. 기본 true. + [JsonPropertyName("enableCronTools")] + public bool EnableCronTools { get; set; } = true; } /// 사용자 정의 커스텀 프리셋 (settings.json에 저장). diff --git a/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs b/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs index cfb5861..27d8a09 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs @@ -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) diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index e5c3c06..5f9afa3 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -1536,7 +1536,8 @@ public partial class AgentLoopService IEnumerable? 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 MergeDisabledTools(IEnumerable? disabledToolNames) + { + var disabled = new HashSet(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 ParseAllowedToolNames(string? raw) { var result = new HashSet(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 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}"); diff --git a/src/AxCopilot/Services/Agent/CronCreateTool.cs b/src/AxCopilot/Services/Agent/CronCreateTool.cs new file mode 100644 index 0000000..0f0a234 --- /dev/null +++ b/src/AxCopilot/Services/Agent/CronCreateTool.cs @@ -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 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})")); + } +} diff --git a/src/AxCopilot/Services/Agent/CronDeleteTool.cs b/src/AxCopilot/Services/Agent/CronDeleteTool.cs new file mode 100644 index 0000000..6a37a5e --- /dev/null +++ b/src/AxCopilot/Services/Agent/CronDeleteTool.cs @@ -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 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.")); + } +} diff --git a/src/AxCopilot/Services/Agent/CronListTool.cs b/src/AxCopilot/Services/Agent/CronListTool.cs new file mode 100644 index 0000000..6612fe0 --- /dev/null +++ b/src/AxCopilot/Services/Agent/CronListTool.cs @@ -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 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())); + } +} diff --git a/src/AxCopilot/Services/Agent/CronStore.cs b/src/AxCopilot/Services/Agent/CronStore.cs new file mode 100644 index 0000000..b6a44fb --- /dev/null +++ b/src/AxCopilot/Services/Agent/CronStore.cs @@ -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 Load(string root) + { + var path = GetPath(root); + if (!File.Exists(path)) + return []; + try + { + var json = TextFileCodec.ReadAllText(path).Text; + return JsonSerializer.Deserialize>(json) ?? []; + } + catch + { + return []; + } + } + + internal static void Save(string root, List 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); + } +} diff --git a/src/AxCopilot/Services/Agent/EnterPlanModeTool.cs b/src/AxCopilot/Services/Agent/EnterPlanModeTool.cs new file mode 100644 index 0000000..0790235 --- /dev/null +++ b/src/AxCopilot/Services/Agent/EnterPlanModeTool.cs @@ -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 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.")); + } +} diff --git a/src/AxCopilot/Services/Agent/EnterWorktreeTool.cs b/src/AxCopilot/Services/Agent/EnterWorktreeTool.cs new file mode 100644 index 0000000..0c023d8 --- /dev/null +++ b/src/AxCopilot/Services/Agent/EnterWorktreeTool.cs @@ -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 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}")); + } +} diff --git a/src/AxCopilot/Services/Agent/ExitPlanModeTool.cs b/src/AxCopilot/Services/Agent/ExitPlanModeTool.cs new file mode 100644 index 0000000..a603ac6 --- /dev/null +++ b/src/AxCopilot/Services/Agent/ExitPlanModeTool.cs @@ -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 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.")); + } +} diff --git a/src/AxCopilot/Services/Agent/ExitWorktreeTool.cs b/src/AxCopilot/Services/Agent/ExitWorktreeTool.cs new file mode 100644 index 0000000..d76144e --- /dev/null +++ b/src/AxCopilot/Services/Agent/ExitWorktreeTool.cs @@ -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 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}")); + } +} diff --git a/src/AxCopilot/Services/Agent/IAgentTool.cs b/src/AxCopilot/Services/Agent/IAgentTool.cs index cd42d28..4a43b41 100644 --- a/src/AxCopilot/Services/Agent/IAgentTool.cs +++ b/src/AxCopilot/Services/Agent/IAgentTool.cs @@ -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 _approvedPermissionCache = new(StringComparer.OrdinalIgnoreCase); /// 작업 폴더 경로. - public string WorkFolder { get; init; } = ""; + public string WorkFolder { get; set; } = ""; /// 파일 접근 권한. Ask | Auto | Deny 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); + } } /// 에이전트 이벤트 (UI 표시용). diff --git a/src/AxCopilot/Services/Agent/SkillService.cs b/src/AxCopilot/Services/Agent/SkillService.cs index 93c1809..f034bbe 100644 --- a/src/AxCopilot/Services/Agent/SkillService.cs +++ b/src/AxCopilot/Services/Agent/SkillService.cs @@ -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", diff --git a/src/AxCopilot/Services/Agent/TaskBoardStore.cs b/src/AxCopilot/Services/Agent/TaskBoardStore.cs new file mode 100644 index 0000000..abaa204 --- /dev/null +++ b/src/AxCopilot/Services/Agent/TaskBoardStore.cs @@ -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 Load(string workFolder) + { + var path = GetStorePath(workFolder); + if (!File.Exists(path)) + return []; + + try + { + var json = TextFileCodec.ReadAllText(path).Text; + return JsonSerializer.Deserialize>(json) ?? []; + } + catch + { + return []; + } + } + + internal static void Save(string workFolder, List 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 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"; +} diff --git a/src/AxCopilot/Services/Agent/TaskCreateTool.cs b/src/AxCopilot/Services/Agent/TaskCreateTool.cs new file mode 100644 index 0000000..28031ce --- /dev/null +++ b/src/AxCopilot/Services/Agent/TaskCreateTool.cs @@ -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 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}]")); + } +} diff --git a/src/AxCopilot/Services/Agent/TaskGetTool.cs b/src/AxCopilot/Services/Agent/TaskGetTool.cs new file mode 100644 index 0000000..a0779cf --- /dev/null +++ b/src/AxCopilot/Services/Agent/TaskGetTool.cs @@ -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 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())); + } +} diff --git a/src/AxCopilot/Services/Agent/TaskListTool.cs b/src/AxCopilot/Services/Agent/TaskListTool.cs new file mode 100644 index 0000000..82cff92 --- /dev/null +++ b/src/AxCopilot/Services/Agent/TaskListTool.cs @@ -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 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())); + } +} diff --git a/src/AxCopilot/Services/Agent/TaskOutputTool.cs b/src/AxCopilot/Services/Agent/TaskOutputTool.cs new file mode 100644 index 0000000..2dcff22 --- /dev/null +++ b/src/AxCopilot/Services/Agent/TaskOutputTool.cs @@ -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 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}.")); + } +} diff --git a/src/AxCopilot/Services/Agent/TaskStopTool.cs b/src/AxCopilot/Services/Agent/TaskStopTool.cs new file mode 100644 index 0000000..75fe95a --- /dev/null +++ b/src/AxCopilot/Services/Agent/TaskStopTool.cs @@ -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 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}")); + } +} diff --git a/src/AxCopilot/Services/Agent/TaskUpdateTool.cs b/src/AxCopilot/Services/Agent/TaskUpdateTool.cs new file mode 100644 index 0000000..019abe5 --- /dev/null +++ b/src/AxCopilot/Services/Agent/TaskUpdateTool.cs @@ -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 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}")); + } +} diff --git a/src/AxCopilot/Services/Agent/TeamCreateTool.cs b/src/AxCopilot/Services/Agent/TeamCreateTool.cs new file mode 100644 index 0000000..6da9534 --- /dev/null +++ b/src/AxCopilot/Services/Agent/TeamCreateTool.cs @@ -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 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}")); + } +} diff --git a/src/AxCopilot/Services/Agent/TeamDeleteTool.cs b/src/AxCopilot/Services/Agent/TeamDeleteTool.cs new file mode 100644 index 0000000..76f6440 --- /dev/null +++ b/src/AxCopilot/Services/Agent/TeamDeleteTool.cs @@ -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 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.")); + } +} diff --git a/src/AxCopilot/Services/Agent/TeamStore.cs b/src/AxCopilot/Services/Agent/TeamStore.cs new file mode 100644 index 0000000..9afce75 --- /dev/null +++ b/src/AxCopilot/Services/Agent/TeamStore.cs @@ -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 Load(string root) + { + var path = GetPath(root); + if (!File.Exists(path)) + return []; + try + { + var json = TextFileCodec.ReadAllText(path).Text; + return JsonSerializer.Deserialize>(json) ?? []; + } + catch + { + return []; + } + } + + internal static void Save(string root, List 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); + } +} diff --git a/src/AxCopilot/Services/Agent/TodoWriteTool.cs b/src/AxCopilot/Services/Agent/TodoWriteTool.cs new file mode 100644 index 0000000..bbd742d --- /dev/null +++ b/src/AxCopilot/Services/Agent/TodoWriteTool.cs @@ -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 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(); + 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); + } +} diff --git a/src/AxCopilot/Services/Agent/ToolRegistry.cs b/src/AxCopilot/Services/Agent/ToolRegistry.cs index b1fb788..4f0521f 100644 --- a/src/AxCopilot/Services/Agent/ToolRegistry.cs +++ b/src/AxCopilot/Services/Agent/ToolRegistry.cs @@ -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()); diff --git a/src/AxCopilot/Services/Agent/ToolSearchTool.cs b/src/AxCopilot/Services/Agent/ToolSearchTool.cs new file mode 100644 index 0000000..27c5fa9 --- /dev/null +++ b/src/AxCopilot/Services/Agent/ToolSearchTool.cs @@ -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> _toolProvider; + + public ToolSearchTool(Func> 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 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 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; + } +} diff --git a/src/AxCopilot/Services/Agent/WorktreeStateStore.cs b/src/AxCopilot/Services/Agent/WorktreeStateStore.cs new file mode 100644 index 0000000..82c1df0 --- /dev/null +++ b/src/AxCopilot/Services/Agent/WorktreeStateStore.cs @@ -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(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); + } +} diff --git a/src/AxCopilot/Views/SettingsWindow.xaml b/src/AxCopilot/Views/SettingsWindow.xaml index 93ab2d0..92d8487 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml +++ b/src/AxCopilot/Views/SettingsWindow.xaml @@ -4839,6 +4839,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +