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

@@ -11,6 +11,7 @@
- 기능/로직 구현은 `claude-code` 실제 코드 흐름을 우선 참고하여 동등 품질 기준으로 반영합니다. (실제 폴더: `claw-code`) - 기능/로직 구현은 `claude-code` 실제 코드 흐름을 우선 참고하여 동등 품질 기준으로 반영합니다. (실제 폴더: `claw-code`)
- `claude-code`와 추후 비교/대조 시 문제 없도록 **기본 로직(동작 순서·예외 처리·검증 흐름)은 유지**하되, 코드 표현(변수명·함수 분해·구조화)은 가독성과 제품 코드 규칙에 맞게 변경하여 반영합니다. - `claude-code`와 추후 비교/대조 시 문제 없도록 **기본 로직(동작 순서·예외 처리·검증 흐름)은 유지**하되, 코드 표현(변수명·함수 분해·구조화)은 가독성과 제품 코드 규칙에 맞게 변경하여 반영합니다.
- 작업 완료 후에는 변경사항을 점검하고 **반드시 Git push까지 진행**합니다. - 작업 완료 후에는 변경사항을 점검하고 **반드시 Git push까지 진행**합니다.
- Git 커밋/푸시 시 커밋 메시지는 **반드시 한국어로 작성**하며, 변경 목적·핵심 수정사항·검증 결과가 드러나도록 **상세하게** 작성합니다.
- 작업 중 오류가 발생해 복구가 되지 않으면, **이전 정상 버전을 다시 받아 기준 상태에서 작업을 재개**합니다. - 작업 중 오류가 발생해 복구가 되지 않으면, **이전 정상 버전을 다시 받아 기준 상태에서 작업을 재개**합니다.
### 개발 계획 수립 기준 (필수) ### 개발 계획 수립 기준 (필수)

View File

@@ -34,9 +34,9 @@
3. 패리티 수치(테스트 통과 수/게이트 상태)를 로드맵 문서 간 동일 문구로 유지. 3. 패리티 수치(테스트 통과 수/게이트 상태)를 로드맵 문서 간 동일 문구로 유지.
## 6. 최신 검증 스냅샷 (2026-04-03) ## 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 --filter "Suite=ReplayStability"`: 12/12 통과.
- `dotnet test`: 361/361 통과. - `dotnet test`: 371/371 통과.
## 7. 권한 Hook 계약 (P2 마감 기준) ## 7. 권한 Hook 계약 (P2 마감 기준)
- lifecycle hook 키: - lifecycle hook 키:

View File

@@ -40,7 +40,7 @@
## 6. 2026-04-03 점검 스냅샷 ## 6. 2026-04-03 점검 스냅샷
- 기준 시점: 2026-04-03. - 기준 시점: 2026-04-03.
- 계획 대비 현재 수준: 약 92~95%. - 계획 대비 현재 수준: 약 92~95%.
- 테스트 상태: `dotnet test` 361/361 통과. - 테스트 상태: `dotnet test` 371/371 통과.
- P1 Hook 계약: 구현 완료 수준. - P1 Hook 계약: 구현 완료 수준.
- P2 세션/이벤트 내구성: 구현 완료 수준(복원/재생 경계 케이스 테스트 반영). - P2 세션/이벤트 내구성: 구현 완료 수준(복원/재생 경계 케이스 테스트 반영).
- P3 실패 복구 표준화: 구현 완료 수준(unknown-tool/권한/정체/fork 강제 흐름 반영). - P3 실패 복구 표준화: 구현 완료 수준(unknown-tool/권한/정체/fork 강제 흐름 반영).
@@ -55,7 +55,7 @@
- 레거시 도구명 `process_run` 참조: 0건 (`process`로 정규화). - 레거시 도구명 `process_run` 참조: 0건 (`process`로 정규화).
- 레거시 도구명 `grep_tool` 참조: 0건 (`grep`로 정규화). - 레거시 도구명 `grep_tool` 참조: 0건 (`grep`로 정규화).
- 내부 모드 차단 정책: `http_tool` 전면 차단, `open_external`의 외부 URL 차단. - 내부 모드 차단 정책: `http_tool` 전면 차단, `open_external`의 외부 URL 차단.
- 테스트 상태: `dotnet test` 361/361 통과. - 테스트 상태: `dotnet test` 371/371 통과.
## 8. claw-code 소스 직접 비교 결과 (2026-04-03) ## 8. claw-code 소스 직접 비교 결과 (2026-04-03)
- 비교 기준 소스: `claw-code/.../src/tools.ts`, `src/Tool.ts`, `src/skills/loadSkillsDir.ts`, `src/skills/bundled/*.ts`. - 비교 기준 소스: `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`이 실제 도구 입력에 적용됨 | | Hook 입력 변형 반영 | `AgentLoopE2ETests.RunAsync_PreHookInputMutation_ChangesToolArguments` | pre-hook `updatedInput`이 실제 도구 입력에 적용됨 |
| Runtime 정책(`allowed_tools`) 강제 | `AgentLoopE2ETests.RunAsync_DisallowedTool_ByRuntimePolicy_EmitsPolicyRecoveryError` | 비허용 도구 차단 + 정책 복구 경고 후 종료 | | Runtime 정책(`allowed_tools`) 강제 | `AgentLoopE2ETests.RunAsync_DisallowedTool_ByRuntimePolicy_EmitsPolicyRecoveryError` | 비허용 도구 차단 + 정책 복구 경고 후 종료 |
| Hook filter 정합성 | `AgentLoopE2ETests.RunAsync_HookFilters_ExecuteOnlyMatchingHookForToolAndTiming` | 지정된 hook만 실행되고 비매칭 hook는 미실행 | | 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. 1. `dotnet build` 경고 0/오류 0.
2. `dotnet test` 전체 통과 (`361/361` 기준, 증가 시 최신 값으로 동기화). 2. `dotnet test` 전체 통과 (`371/371` 기준, 증가 시 최신 값으로 동기화).
3.7개 시나리오의 회귀 테스트가 모두 통과. 3.8개 시나리오의 회귀 테스트가 모두 통과.
4. 패리티 수치/상태를 `NEXT_ROADMAP.md`와 동일 문구로 동기화. 4. 패리티 수치/상태를 `NEXT_ROADMAP.md`와 동일 문구로 동기화.
5. 릴리즈 전 게이트 스크립트 실행: `powershell -ExecutionPolicy Bypass -File .\scripts\release-gate.ps1` 5. 릴리즈 전 게이트 스크립트 실행: `powershell -ExecutionPolicy Bypass -File .\scripts\release-gate.ps1`
### 실행 증적 (2026-04-03) ### 실행 증적 (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 통과. - `powershell -ExecutionPolicy Bypass -File .\scripts\release-gate.ps1`: build/replay/full gate 통과.
## 13. 세션 Replay 안정성 기준 (고정) ## 13. 세션 Replay 안정성 기준 (고정)

View File

@@ -36,7 +36,7 @@
## 7. 2026-04-03 실행 증적 동기화 (M4 포함) ## 7. 2026-04-03 실행 증적 동기화 (M4 포함)
- 기준 시점: 2026-04-03. - 기준 시점: 2026-04-03.
- 테스트: `dotnet test` 361/361 통과. - 테스트: `dotnet test` 371/371 통과.
- M1 증적: Hook 계약 필드(`updatedInput`, `updatedPermissions`, `additionalContext`) 반영 경로 구현 완료. - M1 증적: Hook 계약 필드(`updatedInput`, `updatedPermissions`, `additionalContext`) 반영 경로 구현 완료.
- M2 증적: run 복원/이력 재구성(`RestoreRecentFromExecutionEvents`, `RestoreCurrentAgentRun`, plan 이력 조회) 구현 및 테스트 존재. - M2 증적: run 복원/이력 재구성(`RestoreRecentFromExecutionEvents`, `RestoreCurrentAgentRun`, plan 이력 조회) 구현 및 테스트 존재.
- M3 증적: unknown-tool 복구 루프/결정 이벤트 처리 경로 구현 및 테스트 존재. - M3 증적: unknown-tool 복구 루프/결정 이벤트 처리 경로 구현 및 테스트 존재.
@@ -56,7 +56,7 @@
- 기준 문서: `docs/CLAW_CODE_PARITY_PLAN.md` 13절. - 기준 문서: `docs/CLAW_CODE_PARITY_PLAN.md` 13절.
- 테스트 태그: `Suite=ReplayStability`. - 테스트 태그: `Suite=ReplayStability`.
- 운영 기준: 릴리즈 전 `ReplayStability` 시나리오 전건 통과 시 replay 불일치 0건으로 판정. - 운영 기준: 릴리즈 전 `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`로 빌드/벤치마크/리플레이/전체 테스트를 일괄 점검. - 실행 자동화: `scripts/release-gate.ps1`로 빌드/벤치마크/리플레이/전체 테스트를 일괄 점검.
## 11. 권한 Hook 계약 고정 (M1 완료 기준) ## 11. 권한 Hook 계약 고정 (M1 완료 기준)

View File

@@ -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<AgentEvent>();
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<AgentEvent>();
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<AgentEvent>();
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) private static SettingsService BuildLoopSettings(string endpoint)
{ {
var settings = new SettingsService(); var settings = new SettingsService();

View File

@@ -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 { }
}
}
}

View File

@@ -59,4 +59,47 @@ public class OperationModePolicyTests
var allowed = await context.CheckToolPermissionAsync("http_tool", "https://example.com"); var allowed = await context.CheckToolPermissionAsync("http_tool", "https://example.com");
allowed.Should().BeTrue(); allowed.Should().BeTrue();
} }
[Fact]
public void AgentContext_GetEffectiveToolPermission_PrefersPatternRule()
{
var context = new AgentContext
{
Permission = "Ask",
ToolPermissions = new Dictionary<string, string>(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<string, string>(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();
}
} }

View File

@@ -1,6 +1,7 @@
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Reflection; using System.Reflection;
using System.Text.Json;
using AxCopilot.Handlers; using AxCopilot.Handlers;
using AxCopilot.SDK; using AxCopilot.SDK;
using AxCopilot.Services; using AxCopilot.Services;
@@ -15,6 +16,9 @@ public class PluginHost
private readonly SettingsService _settings; private readonly SettingsService _settings;
private readonly CommandResolver _resolver; private readonly CommandResolver _resolver;
private readonly List<IActionHandler> _loadedPlugins = new(); private readonly List<IActionHandler> _loadedPlugins = new();
private readonly string _pluginsRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "plugins");
public IReadOnlyList<IActionHandler> LoadedPlugins => _loadedPlugins; public IReadOnlyList<IActionHandler> LoadedPlugins => _loadedPlugins;
@@ -58,6 +62,12 @@ public class PluginHost
return; return;
} }
if (!IsPluginPathAllowed(dllPath))
{
LogService.Warn($"플러그인 경로 정책 차단: {dllPath}");
return;
}
try try
{ {
var assembly = Assembly.LoadFrom(dllPath); var assembly = Assembly.LoadFrom(dllPath);
@@ -152,6 +162,14 @@ public class PluginHost
entry.ExtractToFile(destPath, overwrite: true); 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 파일 찾아서 플러그인으로 등록 // .dll 파일 찾아서 플러그인으로 등록
foreach (var dllFile in Directory.EnumerateFiles(targetDir, "*.dll")) foreach (var dllFile in Directory.EnumerateFiles(targetDir, "*.dll"))
{ {
@@ -235,4 +253,84 @@ public class PluginHost
return false; 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;
}
}
} }

View File

@@ -1023,6 +1023,22 @@ public class CodeSettings
/// </summary> /// </summary>
[JsonPropertyName("enableCodeVerification")] [JsonPropertyName("enableCodeVerification")]
public bool EnableCodeVerification { get; set; } = false; public bool EnableCodeVerification { get; set; } = false;
/// <summary>Code 탭에서 Plan Mode 도구(enter/exit plan mode) 사용 여부. 기본 true.</summary>
[JsonPropertyName("enablePlanModeTools")]
public bool EnablePlanModeTools { get; set; } = true;
/// <summary>Code 탭에서 Worktree 도구(enter/exit worktree) 사용 여부. 기본 true.</summary>
[JsonPropertyName("enableWorktreeTools")]
public bool EnableWorktreeTools { get; set; } = true;
/// <summary>Code 탭에서 Team 도구(team create/delete) 사용 여부. 기본 true.</summary>
[JsonPropertyName("enableTeamTools")]
public bool EnableTeamTools { get; set; } = true;
/// <summary>Code 탭에서 Cron 도구(cron create/list/delete) 사용 여부. 기본 true.</summary>
[JsonPropertyName("enableCronTools")]
public bool EnableCronTools { get; set; } = true;
} }
/// <summary>사용자 정의 커스텀 프리셋 (settings.json에 저장).</summary> /// <summary>사용자 정의 커스텀 프리셋 (settings.json에 저장).</summary>

View File

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

View File

@@ -1536,7 +1536,8 @@ public partial class AgentLoopService
IEnumerable<string>? disabledToolNames, IEnumerable<string>? disabledToolNames,
SkillRuntimeOverrides? runtimeOverrides) SkillRuntimeOverrides? runtimeOverrides)
{ {
var active = _tools.GetActiveTools(disabledToolNames); var mergedDisabled = MergeDisabledTools(disabledToolNames);
var active = _tools.GetActiveTools(mergedDisabled);
if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0) if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0)
return active; return active;
@@ -1546,6 +1547,50 @@ public partial class AgentLoopService
.AsReadOnly(); .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) private static HashSet<string> ParseAllowedToolNames(string? raw)
{ {
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -3510,6 +3555,25 @@ public partial class AgentLoopService
["task"] = "spawn_agent", ["task"] = "spawn_agent",
["sendmessage"] = "notify_tool", ["sendmessage"] = "notify_tool",
["powershell"] = "process", ["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) private static string ResolveRequestedToolName(string requestedToolName, IReadOnlyCollection<string> activeToolNames)
@@ -3858,9 +3922,11 @@ public partial class AgentLoopService
private AgentContext BuildContext() private AgentContext BuildContext()
{ {
var llm = _settings.Settings.Llm; var llm = _settings.Settings.Llm;
var baseWorkFolder = llm.WorkFolder;
var runtimeWorkFolder = ResolveRuntimeWorkFolder(baseWorkFolder);
return new AgentContext return new AgentContext
{ {
WorkFolder = llm.WorkFolder, WorkFolder = runtimeWorkFolder,
Permission = llm.FilePermission, Permission = llm.FilePermission,
BlockedPaths = llm.BlockedPaths, BlockedPaths = llm.BlockedPaths,
BlockedExtensions = llm.BlockedExtensions, 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) private static string DescribeToolTarget(string toolName, JsonElement input, AgentContext context)
{ {
static string? TryReadString(JsonElement inputElement, params string[] names) static string? TryReadString(JsonElement inputElement, params string[] names)
@@ -4049,7 +4143,7 @@ public partial class AgentLoopService
runId = _currentRunId, runId = _currentRunId,
tool = toolName, tool = toolName,
target, target,
permission = context.GetEffectiveToolPermission(toolName) permission = context.GetEffectiveToolPermission(toolName, target)
}); });
await RunPermissionLifecycleHooksAsync( await RunPermissionLifecycleHooksAsync(
"__permission_request__", "__permission_request__",
@@ -4059,7 +4153,7 @@ public partial class AgentLoopService
messages, messages,
success: true); success: true);
var effectivePerm = context.GetEffectiveToolPermission(toolName); var effectivePerm = context.GetEffectiveToolPermission(toolName, target);
if (string.Equals(effectivePerm, "Ask", StringComparison.OrdinalIgnoreCase)) if (string.Equals(effectivePerm, "Ask", StringComparison.OrdinalIgnoreCase))
EmitEvent(AgentEventType.PermissionRequest, toolName, $"권한 확인 필요 · 대상: {target}"); 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.IO;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace AxCopilot.Services.Agent; namespace AxCopilot.Services.Agent;
@@ -95,7 +96,7 @@ public class AgentContext
private readonly object _permissionLock = new(); private readonly object _permissionLock = new();
private readonly HashSet<string> _approvedPermissionCache = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _approvedPermissionCache = new(StringComparer.OrdinalIgnoreCase);
/// <summary>작업 폴더 경로.</summary> /// <summary>작업 폴더 경로.</summary>
public string WorkFolder { get; init; } = ""; public string WorkFolder { get; set; } = "";
/// <summary>파일 접근 권한. Ask | Auto | Deny</summary> /// <summary>파일 접근 권한. Ask | Auto | Deny</summary>
public string Permission { get; init; } = "Ask"; public string Permission { get; init; } = "Ask";
@@ -186,8 +187,13 @@ public class AgentContext
return await CheckToolPermissionAsync(toolName, filePath); 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) && if (ToolPermissions.TryGetValue(toolName, out var toolPerm) &&
!string.IsNullOrWhiteSpace(toolPerm)) !string.IsNullOrWhiteSpace(toolPerm))
return toolPerm; return toolPerm;
@@ -207,7 +213,7 @@ public class AgentContext
&& AxCopilot.Services.OperationModePolicy.IsBlockedAgentToolInInternalMode(toolName, target)) && AxCopilot.Services.OperationModePolicy.IsBlockedAgentToolInInternalMode(toolName, target))
return false; return false;
var effectivePerm = GetEffectiveToolPermission(toolName); var effectivePerm = GetEffectiveToolPermission(toolName, target);
if (string.Equals(effectivePerm, "Deny", StringComparison.OrdinalIgnoreCase)) return false; if (string.Equals(effectivePerm, "Deny", StringComparison.OrdinalIgnoreCase)) return false;
if (string.Equals(effectivePerm, "Auto", StringComparison.OrdinalIgnoreCase)) return true; if (string.Equals(effectivePerm, "Auto", StringComparison.OrdinalIgnoreCase)) return true;
if (AskPermission == null) return false; if (AskPermission == null) return false;
@@ -228,6 +234,67 @@ public class AgentContext
} }
return allowed; 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> /// <summary>에이전트 이벤트 (UI 표시용).</summary>

View File

@@ -462,6 +462,23 @@ public static class SkillService
["LSP"] = "lsp_code_intel", ["LSP"] = "lsp_code_intel",
["ListMcpResourcesTool"] = "mcp_list_resources", ["ListMcpResourcesTool"] = "mcp_list_resources",
["ReadMcpResourceTool"] = "mcp_read_resource", ["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", ["Agent"] = "spawn_agent",
["Task"] = "spawn_agent", ["Task"] = "spawn_agent",
["SendMessage"] = "notify_tool", ["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 WaitAgentsTool());
registry.Register(new CodeSearchTool()); registry.Register(new CodeSearchTool());
registry.Register(new TestLoopTool()); registry.Register(new TestLoopTool());
registry.Register(new ToolSearchTool(() => registry.All));
// 코드 리뷰 + 프로젝트 규칙 // 코드 리뷰 + 프로젝트 규칙
registry.Register(new CodeReviewTool()); registry.Register(new CodeReviewTool());
@@ -179,6 +180,22 @@ public class ToolRegistry : IDisposable
// 태스크 추적 // 태스크 추적
registry.Register(new TaskTrackerTool()); 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()); 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);
}
}

View File

@@ -4839,6 +4839,49 @@
</Grid> </Grid>
</Border> </Border>
<!-- ── Agentic 도구 설정 ── -->
<TextBlock Style="{StaticResource SectionHeader}" Text="Agentic 도구"/>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<TextBlock Style="{StaticResource RowLabel}" Text="Plan Mode 도구"/>
<TextBlock Style="{StaticResource RowHint}" Text="EnterPlanMode/ExitPlanMode 도구를 사용할지 설정합니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding Code.EnablePlanModeTools, Mode=TwoWay}"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<TextBlock Style="{StaticResource RowLabel}" Text="Worktree 도구"/>
<TextBlock Style="{StaticResource RowHint}" Text="EnterWorktree/ExitWorktree 도구를 사용할지 설정합니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding Code.EnableWorktreeTools, Mode=TwoWay}"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<TextBlock Style="{StaticResource RowLabel}" Text="Team 도구"/>
<TextBlock Style="{StaticResource RowHint}" Text="TeamCreate/TeamDelete 도구를 사용할지 설정합니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding Code.EnableTeamTools, Mode=TwoWay}"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<TextBlock Style="{StaticResource RowLabel}" Text="Cron 도구"/>
<TextBlock Style="{StaticResource RowHint}" Text="CronCreate/CronList/CronDelete 도구를 사용할지 설정합니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding Code.EnableCronTools, Mode=TwoWay}"/>
</Grid>
</Border>
<!-- ── 코드 인텔리전스 (v1.4.0) ── --> <!-- ── 코드 인텔리전스 (v1.4.0) ── -->
<TextBlock Style="{StaticResource SectionHeader}" Text="코드 인텔리전스"/> <TextBlock Style="{StaticResource SectionHeader}" Text="코드 인텔리전스"/>
<Border Style="{StaticResource SettingsRow}"> <Border Style="{StaticResource SettingsRow}">