모든 명령을 정확하게 라우팅할 수 있도록: HagiCode Preset Task의 다중 스킬 지원 실전
모든 명령을 정확하게 라우팅할 수 있도록: HagiCode Preset Task의 다중 스킬 지원 실전
하나의 preset에 여러 명령이 들어있는데, 하나의 스킬 요구사항만 공유할 수밖에 없나요? 이번 개선을 통해 각 명령이 자신이 의존하는 스킬을 독립적으로 선언할 수 있게 하고, 시각화 패널에서 이러한 바인딩을 표시하도록 했습니다—배지, 요약, 원클릭 설치를 한 번에 완료합니다.
배경
먼저 배경부터 말씀드리겠습니다.
HagiCode의 preset task는 플러그인 기반의 작은 도구 시스템입니다. 사용자는 명령을 직접 입력할 필요 없이 시각화 패널에서 몇 개의 필드를 채우고 클릭만 하면 자동 작업 세션을 생성할 수 있습니다. 각 preset은 기본적으로 디렉터리이며, 내부는 보통 다음과 같이 구성됩니다:
manifest.json: preset의 신원 정보panel.json: 시각화 패널의 양식 정의commands.json: 실제 실행할 명령 목록task-preset.json또는prompts.json: 작업 매개변수와 스킬 요구사항
이 시스템은 확실히 편리하지만, 우리는 곧 어색한 부분에 부딪혔습니다.
초기 버전에서는 스킬을 preset 레벨의 requirements 배열에서만 선언할 수 있었습니다. 이게 무슨 뜻이냐면, 같은 preset 내의 모든 명령이 동일한 스킬 요구사항을 공유한다는 뜻입니다. 별로 문제가 없어 보이지만, 실제로는 다음과 같은 시나리오가 발생합니다:
한 preset에 다섯 개의 명령이 있는데, 첫 번째 명령은 last30days 스킬을 사용하고 싶고, 세 번째 명령은 ui-master를 사용하고 싶으며, 나머지 세 개는 어떤 스킬도 필요하지 않습니다. 이전 디자인에서는 불가능했습니다. 다른 명령을 다른 스킬로 라우팅하려면 이 명령들을 강제로 여러 preset으로 분리해야 했고, 설정이 금방 비대해졌습니다.
이것이 extend-preset-task-multiple-skills-support 제안이 해결하고자 하는 문제입니다: 각 명령이 자신이 의존하는 스킬을 독립적으로 선언할 수 있게 하고, 이러한 바인딩을 UI에서 시각화하도록 합니다.
HagiCode에 대해
이 문서에서 공유하는 솔루션은 우리가 HagiCode 프로젝트에서 축적한 실전 경험에서 나온 것입니다. HagiCode는 AI 코드 어시스턴트 프로젝트로, preset task 시스템은 사용자를 위한 빠른 작업 진입점입니다. 아래에서 언급하는 모든 변경 사항은 우리가 실제로 겪은 문제와 실제 최적화 결과입니다—毕竟纸上得来终觉浅(종이 위에서 얻은 것은 항상 얕습니다). 프로젝트 소스 코드는 HagiCode-org/site에 있으며, 관심이 있다면 먼저 Star를 눌러보세요.
먼저 문제를 명확하게 하기: 왜 매핑 테이블이 아닌가
작업에 들어가기 전에 가장 먼저 떠오르는 솔루션은: commandSkillMappings 매핑 테이블을 하나 더 만들고 “명령 ID → 스킬” 관계를 따로 저장하는 것입니다. 꽤 깔끔해 보이고, 책임 분리라니까요.
하지만 자세히 생각해보면 문제가 있습니다.
commands.json의 각 명령에는 이미 ID가 있는데, 매핑 테이블에서 이 ID를 다시 한 번 복사해야 합니다. 두 개의 파일, 동일한 ID, 언젠가 누군가 명령을 수정하고 매핑 테이블 동기를 잊어버리면 데이터가 이탈합니다. 이러한 “분리를 위한 분리” 디자인은 후기 유지 관리 비용이 그것이 가져오는 깔끔함보다 훨씬 큽니다. 결국 고민만 늘어날 뿐입니다.
그래서 우리는 더 직접적인 경로를 선택했습니다: 선택적 skill 필드를 명령 정의에 직접 추가. 한 명령이 자신이 바인딩할 스킬을 스스로 선언하고, 가까이에서 유지 관리하면 누구도 연결이 끊어지지 않습니다.
이 결정 뒤에는 더 중요한 디자인 원칙이 있어서 따로 언급할 가치가 있습니다.
핵심 1: 두 계층의 데이터 책임 분리
이것은 전체 개선에서 가장 중요한 인식입니다.
많은 사람들의 첫 번째 반응은: 명령에 skill이 있으니 requirement check(스킬 게이트 확인)를 할 때 각 명령의 skill 필드를 스캔해야 하지 않을까?
아닙니다.
우리는 의도적으로 이것을 두 계층으로 분리했습니다:
commands.json의skill필드: 바인딩 선언만 담당합니다. 시스템에 “이 명령이 어떤 스킬에 바인딩되어 있는지” 알려주며, prompt 전도와 UI 표시에 사용됩니다.task-preset.json의requirements배열: 진짜 권위 있는 열거형입니다. 실제 게이트로, preset이 실행되려면 어떤 스킬을 만족해야 하는지 결정합니다.
다시 말해, skill은 “어떤 것에 바인딩할 것인가, 무엇을 렌더링할 것인가”에 답하고, requirements는 “실제로 실행할 수 있는지”에 답합니다. 두 가지 다른 일이니 섞지 마세요.
이렇게 분리하면 check 로직이 자연스럽게 단순해집니다. 게이트가 항상 preset 레벨의 requirements를 기반으로 하고 CacheKey로 중복 제거하기 때문에, 여러 명령이 같은 스킬에 바인딩되어도 한 번만 탐지하고 중복으로 체크하지 않습니다. 명령 레벨 스킬은 추가 탐지 오버헤드를 전혀 도입하지 않습니다.
이 원칙이 우리가 매핑 테이블 솔루션을 거부한 근본적인 이유이기도 합니다—매핑 테이블은 사람들에게 “바인딩이 곧 게이트”라는 오해를 주어 두 계층의 책임을 다시 섞어버립니다. 지나친 똑똑함이 독이 되는 경우입니다.
핵심 2: 명령 정의는 어떻게 생겼는가
개선된 명령 정의는 원래 기반에 선택적 skill 필드를 하나 더 추가한 것입니다. last30days라는 bundled preset을 예로 들면, 그 commands.json은 대략 다음과 같습니다:
{ "$schema": "../../schemas/commands.schema.json", "version": "1.1", "commands": [ { "id": "research", "skill": "last30days", "prompt": "调研一下最近30天大家对 {topic} 的真实讨论" }, { "id": "summarize", "prompt": "把上面的调研结果整理成一份摘要" } ]}몇 가지 주요 사항:
version이1.1로 상향되었고, 해당 스키마에도 선택적skill필드가 추가되었습니다.- 첫 번째 명령
research는last30days스킬에 바인딩되었으며, 실행 시 이 스킬로 라우팅됩니다. - 두 번째 명령
summarize는 스킬에 바인딩되지 않았으며, 일반 명령으로 기본 경로를 따릅니다. - 여기에는 명령에 어떤 requirement도 작성하지 않았습니다. 실제 게이트는
task-preset.json의requirements에 있습니다:
{ "requirements": [ { "key": "last30days", "cacheKey": "skill:last30days" } ]}research 명령에 바인딩된 last30days는 반드시 이 requirements에 나타나야 하고, 그렇지 않으면 문제가 발생합니다—이것이 바로 다음 섹션에서 설명할 강력한 제약입니다. 억지로 된 과일은 달지 않습니다.
핵심 3: 로딩 기간의 교차 유효성 검사
데이터에서 바인딩을 선언하는 것만으로는 충분하지 않습니다. “명령에 스킬이 바인딩되었는데 requirements에는 선언되지 않은” 같은 고아 바인딩이 프로덕션에 들어가는 것을 방지하기 위해 누군가가 감시해야 합니다.
이 감시자가 ValidateCommandSkills입니다. 이것은 preset 패키지 로딩 시 한 번 실행되어, 각 명령의 skill이 preset 레벨 requirements에서 해당 항목을 찾을 수 있는지 하나씩 검사합니다. 찾을 수 없으면 불법 패키지로 판단하여 전체 preset을 비활성화하고 진단 코드 command-skill-not-in-requirements를 throw합니다.
왜 전체 패키지를 비활성화하고 그 명령만 건너뛰지 않을까요? preset은 전체이고 명령 사이에는 종종 종속 관계가 있기 때문입니다(이전 명령의 출력이 다음 명령의 입력이 됨). 조용히 하나를 건너뛰면 뒤의 명령이 빈 입력을 받게 되고 동작이 완전히 통제 불가능해집니다. 사람의 마음을 알 수 없듯 코드도 마찬가지입니다. 명확한 오류를 보여주는 것이 작업이 도중에 이상하게 엇나가는 것보다 낫습니다. 이 점, 소홀히 하면 안 됩니다.
이 유효성 검사는 로딩 기간에 완료되므로, 문제는 preset 등록 시점에 발견되며 사용자가 실제로 “실행”을 클릭할 때까지 지연되지 않습니다. 사용자 경험 관점에서, 일찍 발생한 오류는 언제나 늦게 발생한 오류보다 낫습니다.
핵심 4: prompt 전도의 멱등성 연결
다음으로, 실행 체인에서 가장 미묘한 고리입니다.
명령에 스킬이 바인딩되면(예: last30days), 시스템은 실제 실행 전에 이 스킬 정보를 명령 앞에 “붙여서” 실행기에 전달할 완전한 단일 행 명령을 형성합니다. 이 과정은 CombineCommandSkillPrelude가 담당합니다.
구체적인 예를 들어보겠습니다. research 명령의 prompt는 “调研一下最近30天大家对 {topic} 的真实讨论”이고, 바인딩된 스킬은 last30days이므로, 실행기에 전달되는 최종 명령은 대략 다음과 같습니다:
/last30days 调研一下最近30天大家对 {topic} 的真实讨论즉 prompt 앞에 /last30days라는 전도를 추가했습니다. 실행기는 이 전도를 보고 먼저 컨텍스트를 last30days 스킬로 전환해야 한다는 것을 알게 됩니다.
여기에는 쉽게 빠질 수 있는 함정이 있습니다: 멱등성.
왜 멱등성을 강조할까요? 어떤 시나리오에서는 prompt가 이미 이 스킬 전도를 가지고 있을 수 있기 때문입니다(예: 사용자가 직접 절반을 작성했거나 다른 곳에서 복사한 경우). 시스템이 멍청하게 한 번 더 붙이면 /last30days /last30days 调研...가 되고 실행기는 오류를 보내거나 동작이 비정상이 됩니다.
따라서 CombineCommandSkillPrelude는 연결 전에 먼저 감지하고, 접두사가 이미 있으면 중복으로 추가하지 않습니다. 이 단계는 별로 중요해 보이지 않지만, 매우 은밀한 버그 클래스를 막을 수 있습니다.
언급할 가치가 있는 것은 이 전체 전도 주입 로직이 preset 정의 레벨(PresetTaskCatalogProvider의 BuildCommandPrelude)에서 완료되므로, SessionsController 쪽의 세션 생성 코드는 전혀 수정할 필요가 없다는 것입니다. 이것도 책임 분리가 가져온 이점입니다—실행 진입점은 안정적으로 유지되고 스킬 라우팅의 복잡성은 정의 레벨 내부로 수렴됩니다.
핵심 5: 프론트엔드가 바인딩을 어떻게 표시하는가
백엔드가 데이터 모델과 실행 체인을 모두 정리했고, 마지막 단계는 사용자가 인터페이스에서 이러한 바인딩을 “볼 수 있게” 하는 것입니다. 기능을 사용자가 인지하지 못하면 한 것과 다름없기 때문입니다.
프론트엔드에서는 세 가지를 했습니다.
첫째, 명령 선택기에 배지 추가. command-picker에서 스킬에 바인딩된 각 명령 옆에 작은 배지를 표시하여 어떤 스킬에 의존하는지 나타냅니다. 사용자는 한눈에 어떤 명령이 “스킬이 있는” 것인지, 어떤 명령이 일반 명령인지 알 수 있습니다.
둘째, requirement-check 요약 블록. 패널에 현재 preset이 만족해야 하는 모든 스킬 요구사항과 각 명령이 어떤 스킬에 바인딩되어 있는지 나열하는 전용 요약 영역이 있습니다. 이 블록의 데이터는 commandSkillsByRequirementKey라는 매핑에서 옵니다—명령을 바인딩된 requirement key로 그룹화하여 집계하면 사용자가 한눈에 “요구사항”과 “실제 바인딩”이 일치하는지 확인할 수 있습니다. 호랑이를 그리려다 고양이가 되는 격입니다—그러므로 집계 로직은 직관적으로 하고 화려하게 하지 마세요.
셋째, 실패 시 원클릭 설치 딥링크. requirement check에서 특정 스킬이 설치되지 않은 경우, 사용자는 직접 문서를 뒤져 설치 진입점을 찾을 필요가 없습니다. 인터페이스에서 바로 딥링크 버튼을 제공하고, 클릭하면 해당 설치 프로세스로 이동합니다. 이 단계는 “문제 발견”과 “문제 해결” 사이의 거리를 최단으로 압축합니다.
프론트엔드 타입도 매우 절제적이어서, 명령 타입에 skill?: string만 추가했고, 정규화 처리(|| undefined)를 하여 빈 문자열 같은 경계값이 후속 판단에서 문제를 일으키지 않도록 했습니다.
실전: 5단계로 전체 개선 완료
앞에서 언급한 부분들을 모두 연결하면, 전체 개선은 사실 5단계입니다:
- 스키마 확장:
commands.schema.json에 선택적skill필드를 추가하고 버전 번호를1.1로 상향합니다. - 파싱 + 유효성 검사:
NormalizeCommands는 명령 정의 파싱을 담당하고,ValidateCommandSkills는 교차 유효성 검사를 합니다. 명령 스킬은 반드시 preset 레벨 requirements에서 찾을 수 있어야 합니다. - 전도 주입:
BuildCommandPrelude는 실행 전에/skill전도를 명령 앞에 멱등적으로 붙이고,SessionsController는 수정할 필요가 없습니다. - bundled preset 마이그레이션:
last30days와ui-master这两个内置预设的commands.json수정하여 해당 명령에skill필드를 추가합니다. 마이그레이션은 commands.json만 수정하고 다른 파일은 건드리지 않습니다. - 프론트엔드 시각화: 타입 필드 추가, command-picker 배지 추가, requirement-check 요약 블록 추가, 실패 시 원클릭 설치 딥링크 제공.
실전에서의 몇 가지 주의사항을 별도로 나열합니다:
- 한 명령은 하나의 스킬에만 바인딩할 수 있습니다. 이것은 현재의 제약입니다. 시나리오가 정말로 한 명령이 여러 스킬을 트리거해야 한다면 탈출구는 preset 레벨
requirements에 여러 스킬을 선언하여 preset 레벨에서 공존시키는 것입니다. - 유효성 검사 실패 진단 코드는
command-skill-not-in-requirements이며, 문제 해결 시 이 코드를 직접 검색하세요. - 프론트엔드 정규화는
|| undefined를 기억하고, 빈 문자열이 판단 로직에 섞이지 않도록 하세요. - 마이그레이션 시에는 commands.json만 수정하고 requirements 쪽은 그대로 두어 의도치 않은 변경을 도입하지 않도록 하세요.
- 백엔드 테스트는 세 가지 시나리오를 커버합니다: 명령 스킬이 requirements에 있음(통과), 없음(패키지 비활성화), 여러 명령이 동일한 스킬에 바인딩됨(중복 제거 정상).
요약
이번 preset task의 다중 스킬 지원 개선은 표면적으로는 명령에 skill 필드를 하나 추가한 것이지만, 그 뒤에는 꽤 고민할 만한 디자인 문제가 있습니다: 바인딩과 게이트는 분리해야 할까?
우리의 대답은 분리입니다. skill 필드는 “무엇에 바인딩할 것인가, 무엇을 렌더링할 것인가”만 담당하고, requirements가 “실행할 수 있는지”를 담당합니다. 이 두 계층의 책임이 한 번 섞이면, 매핑 테이블이든 다른 형태든 후속 유효성 검사, 중복 제거, UI 표시가 어색해집니다. 분리한 후에는 각 계층이 단순해집니다: 게이트는 항상 하나의 권위 있는 열거형을 기반으로 하고, 바인딩은 가까이 유지 관리되어 이탈하지 않으며, 전도 연결은 멱등적으로 제어 가능하고 UI는 이미 명확한 데이터를 표시할 뿐입니다.
돌이켜보면, 전체 개선에는 화려한 기술이 사용되지 않았고, 책임을 깔끔하게 자르고 각 계층이 감시해야 할 바를 감시하는 것에 의존했습니다. HagiCode의 preset task 시스템은 이번 다듬기를 통해 마침내 각 명령이 정확하게 해당 스킬로 라우팅될 수 있게 되었습니다. 결국, 일은 원래 이렇게 단순해야 합니다…
참고 자료
- HagiCode-org/site: 프로젝트 소스 코드, preset task 시스템의 전체 구현이 여기 있습니다.
- HagiCode 공식 웹사이트: HagiCode의 전체 기능을 알아보세요.
- OpenSpec 제안
extend-preset-task-multiple-skills-support: 이번 개선의 원본 디자인 문서, proposal, design 및 tasks를 포함합니다.
开始使用 HagiCode
一次安装,几分钟上手
HagiCode for Windows 在 Microsoft Store 免费提供。打开商店即可安装并保持更新;也可以先对比各版本与定价,再决定从哪个渠道开始。