gajae-code(GJC)는 코어를 안 건드리고 행동을 덧붙이는 세 확장 구멍 — 끼어드는 콜백(Hooks), 지식 묶음(Skills), 사용자 단축키(Slash-Commands) — 을 “파일을 정해진 곳에 두면 자동 발견된다”는 한 약속 위에 올린다.
→ 왜 배우나: 같은 “확장”이라도 누가 언제 부르느냐에 따라 권한을 얼마나 좁혀야 하는지, 그리고 그 좁힘을 주석이 아니라 타입과 게이트로 강제하는 설계를 배우기 위해서다.
그림
flowchart TD
subgraph Hooks["Hooks · 에이전트가 도는 동안 끼어듦"]
H1["생명주기 이벤트<br/>tool_call·context·session 등"] --> H2[pi.on 핸들러 실행]
H2 --> H3{반환 Result}
H3 -->|"차단·수정"| H4["도구 실행·메시지 변경"]
end
subgraph Skills["Skills · 필요할 때 펼치는 지식"]
S1["부팅: loadSkills"] --> S2["목록에는 이름+설명만<br/>가벼운 카탈로그"]
S2 --> S3[모델이 설명만 훑음]
S3 -->|"skill:// 또는 /skill:"| S4["본문 펼침<br/>임베디드 content 또는 파일"]
end
subgraph Slash["Slash-Commands · 사용자가 직접 호출"]
C1["/명령 입력/"] --> C2{"어떤 종류?"}
C2 -->|builtin| C3[코어 핸들러가 처리]
C2 -->|마크다운| C4[프롬프트로 펼침]
C2 -->|TS 모듈| C5["execute 실행 → 문자열=프롬프트"]
end
쉽게 풀기
세 확장점을 **“한 식당의 세 직원”**으로 비유하면 쉽다.
Hooks = 주방 안 검수원 — 요리(에이전트 루프)가 도는 도중에 끼어든다. “이 재료(도구 호출) 빼라”, “나가기 전 간 봐라(메시지 수정)” 같은 일. 단 검수원에겐 권한을 일부러 좁게 준다. 루프 도중 화면(에디터)을 빼앗으면 흐름이 꼬이고 데드락이 나기 때문. 그래서 받는 UI는 “선택지·확인·알림” 정도뿐, 에디터 교체나 테마 변경은 막혀 있다.
Skills = 필요할 때만 펼치는 매뉴얼 책장 — SKILL.md 한 장이 전문 지식 한 묶음이다. 핵심은 2단계 지연 로딩: 평소엔 제목+한 줄 소개(name+description)만 책장에 꽂고, 모델이 필요하다 싶을 때만 skill://<이름>으로 본문(절차·규칙)을 펼친다. 가벼운 목록 → 필요 시 무거운 본문, 이 구조로 토큰을 아낀다.
Slash-Commands = 손님이 직접 누르는 단축 버튼 — /명령으로 실행. 마크다운형은 인자만 끼워 프롬프트로 펼치는 템플릿, TS형은 코드가 돌아 반환한 문자열이 다시 LLM 프롬프트가 된다(아무것도 안 반환하면 조용히 끝).
공통 약속 — 셋 다 정해진 폴더에 파일을 두면 GJC가 알아서 발견한다(capability discovery). 번들 기본값은 본문이 코드에 박혀(content 임베디드) .gjc 폴더를 통째로 지워도 살아남는다.
핵심 정리
확장점
누가·언제 부르나
한 줄 정체
Hooks
코어가 생명주기 시점에 자동 호출
좁은 권한의 끼어들기 콜백
Skills
모델이 필요할 때 스스로 펼침
지연 로딩되는 지식 묶음
Slash-Commands
사용자가 /명령으로 직접 호출
마크다운·TS 두 갈래 단축키
Hooks의 권한이 왜 두 등급으로 나뉘는지가 이 설계의 핵심이다. 자동 호출 맥락과 사용자 명령 맥락이 받는 권한이 다르다.
flowchart LR
A["자동 호출 훅<br/>HookContext"] -->|"관찰·차단만"| O["ui·cwd·model<br/>읽기전용 session<br/>isIdle·abort"]
B["사용자가 친 명령<br/>HookCommandContext"] -->|위험 제어 추가| P["newSession·branch<br/>navigateTree·waitForIdle"]
A -.데드락 방지로 차단.-> X["시스템프롬프트·shutdown 없음"]
펼쳐보기: Hooks가 좁힌 권한 두 단계
① HookUIContext (ExtensionUIContext보다 좁음) — 에디터 컴포넌트 오버라이드 금지·터미널 입력 리스너 없음·테마 관리 없음. 가진 것: select/confirm/input/notify/setStatus/custom/editor, 에디터 “텍스트만” 만지는 setEditorText/getEditorText, 읽기 전용 theme. 이유는 코드 주석에 박힘 — 훅은 에이전트 루프 안에서 호출되므로 에디터 소유권을 빼앗으면 안 된다.
② HookContext vs HookCommandContext — 자동 호출 일반 훅(HookContext)은 ui/cwd/model/sessionManager(읽기전용)/isIdle()/abort() 등 관찰·차단 위주만(시스템프롬프트·shutdown 없음 — 데드락 방지). 세션을 직접 바꾸는 위험 제어(newSession/branch/navigateTree/waitForIdle)는 사용자가 직접 명령을 친 안전한 맥락 HookCommandContext에만 있다.
frontmatter는 name/description이 필수, argument-hint·level·source·hide는 선택이다. 시스템 프롬프트의 <skills> 리스팅은 name+description만 싣고 본문은 뺀다 — 그래서 카탈로그가 가볍다. read 도구가 있을 때만 노출하고, hide:true는 목록에서 뺀다(여전히 skill://·/skill:<name>으로 로드 가능).
스킬이 목록에서 본문으로 펼쳐지는 경로가 이 절의 핵심이다.
flowchart TD
L[loadSkills 부팅] --> R{"read 도구 있나?"}
R -->|없음| Z["스킬 노출 안 함<br/>펼칠 수단 없음"]
R -->|있음| F{"hide:true?"}
F -->|예| H["목록에서 뺌<br/>(skill://·/skill:로는 여전히 도달)"]
F -->|아니오| LIST["<skills> 목록<br/>name+description만"]
LIST -->|모델 매칭| RES["skill:// 해석"]
H -->|명시 호출| RES
RES --> C{"content 임베디드?"}
C -->|있음| EMB["그 본문 사용<br/>.gjc 삭제에도 생존"]
C -->|없음| FILE["파일 읽고<br/>frontmatter 제거 + Skill 메타"]
펼쳐보기: 전체 코드 — SKILL.md / 리스팅 템플릿 / hide 필터 / skill:// / 본문 주입
<!-- packages/coding-agent/src/defaults/gjc/skills/ralplan/SKILL.md -->---name: ralplandescription: Consensus planning entrypoint that auto-gates vague team/ultragoal requests before executionargument-hint: "[--interactive] [--deliberate] [--architect openai-code] [--critic openai-code] <task description>"level: 4source: "forked from upstream ralplan skill and rebranded for GJC"---# Ralplan (Consensus Planning Alias)...
{{!-- packages/coding-agent/src/prompts/system/custom-system-prompt.md --}}{{#if skills.length}}Skills are specialized knowledge. Scan descriptions for your task domain.If a task-specific instruction applies, you MUST read the referenced local file before proceeding.<skills>{{#list skills join="\n"}}<skill name="{{name}}">{{description}}</skill>{{/list}}</skills>{{/if}}
// packages/coding-agent/src/system-prompt.ts// - hide:true 스킬 제외 (skill:// 와 /skill:<name>으로는 여전히 로드 가능)const hasRead = tools?.has("read");const filteredSkills = hasRead ? skills.filter(skill => skill.hide !== true) : [];
// packages/coding-agent/src/internal-urls/skill-protocol.ts// skill://<name>=SKILL.md, skill://<name>/<path>=baseDir 상대경로(traversal 차단)if (skill.baseDir.startsWith("embedded:")) { throw new Error(`Embedded skill ${skill.name} does not include relative file assets`);}
// packages/coding-agent/src/extensibility/skills.ts — buildSkillPromptMessage// content 있으면 그걸, 없으면 파일 읽어 frontmatter 떼고 Skill: <path> 메타 부착const content = typeof skill.content === "string" ? skill.content : await Bun.file(skill.filePath).text();const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();const metaLines = [`Skill: ${skill.filePath}`];
---name: my-skilldescription: 무엇을 언제 쓰는지 한 줄 (모델이 이걸로 매칭)argument-hint: "<file> [--dry-run]"level: 3---# My Skill1. 절차...참고 자산은 skill://my-skill/notes.md 로 읽어라.
name+description 필수 (description 없으면 로더가 경고/제외)
폴더 구조 <dir>/<skill>/SKILL.md, baseDir에 상대자산 배치
숨기려면 hide: true (여전히 skill://·/skill:로 도달)
본문엔 절차/규칙만 (카탈로그엔 description만 노출)
3) Slash-Commands — 두 로더 + builtin-registry
세 종류가 있고 로드 우선순위로 덮어쓰기를 관리한다.
flowchart TD
IN["/명령 입력/"] --> K{종류}
K -->|builtin| BR["builtin-registry<br/>/provider·/login 직접 처리<br/>subcommands 자동완성"]
K -->|마크다운| MD["FileSlashCommand<br/>{{args}}/ARGUMENTS 치환 → 프롬프트"]
K -->|TS 모듈| TS["execute 실행<br/>string=LLM 프롬프트 / void=종료"]
subgraph 로드순서[로드 우선순위]
B1["번들 기본<br/>최저"] --> B2["user/project<br/>번들 덮어쓰기 가능"]
B2 --> B3{"user↔project<br/>같은 이름?"}
B3 -->|예| ERR[충돌 에러]
end
(a) 마크다운 커맨드 (slash-commands.ts, FileSlashCommand): 필드는 name/description(frontmatter 또는 본문 첫 줄 60자)/content/source. expandSlashCommand가 /명령 인자를 파싱해 {{args}}/ARGUMENTS 치환 후 펼친다. EMBEDDED_COMMAND_TEMPLATES는 번들 디폴트(중복 이름이면 skip, source:"bundled").
(c) builtin-registry: 선언적 정의로 /provider·/login 같은 코어 명령을 직접 처리. subcommands로 드롭다운 자동완성·인라인 힌트 생성.
펼쳐보기: 전체 코드 — 로더 우선순위 / BuiltinSlashCommand
// packages/coding-agent/src/extensibility/custom-commands/loader.ts// 1. 번들 커맨드 먼저 (최저 우선순위 - 덮어쓰기 가능)for (const loaded of loadBundledCommands(sharedApi)) { ... }// 2. user/project 커맨드 (번들 override)// user/project 충돌 → error; 번들 충돌은 splice로 교체
// packages/coding-agent/src/slash-commands/types.tsexport interface BuiltinSlashCommand { name: string; description: string; subcommands?: SubcommandDef[]; // /mcp add, /mcp list 같은 드롭다운 inlineHint?: string;}
내가 직접 만들 때 (TS 커스텀 커맨드 템플릿)
// .gjc/commands/deploy/index.tsimport type { CustomCommandFactory } from "@gajae-code/coding-agent";const factory: CustomCommandFactory = (pi) => ({ name: "deploy", description: "Deploy current branch", async execute(args, ctx) { if (!(await ctx.ui.confirm("Deploy", "진행?"))) return; // void = 프롬프트 없음 const r = await pi.exec("./deploy.sh", [args[0] ?? "staging"]); return `배포 결과:\n${r.stdout}\n다음 단계 제안해줘`; // string = LLM 프롬프트 },});export default factory;
또는 마크다운: .gjc/commands/foo.md에 frontmatter description + 본문({{args}}/ARGUMENTS 치환).
name/description/execute 셋 다 있는가
user/project 같은 이름 충돌은 에러 (번들만 덮어쓰기 가능)
폴더형 커맨드는 index.{ts,js,mjs,cjs} 진입점
4) 한 발 더 — 모호성 게이팅·영수증 패턴
ralplan은 실행 스킬(team/ultragoal)이 모호한 요청(“team improve the app”)으로 시작되는 걸 막는 pre-execution gate를 둔다.
flowchart LR
Q[요청] --> G{"구체 신호 있나?<br/>파일경로·이슈번호·camelCase"}
G -->|1개 이상| PASS["통과 → 실행"]
G -->|"15단어 이하 & 앵커 없음"| RD["redirect (force:/! 로 우회)"]
PASS --> W["단계 산출물<br/>.gjc/plans/ralplan/<run-id>/ 영속"]
W --> RC["역할 에이전트는 RECEIPT-ONLY<br/>run_id·path·sha256·stage만 반환"]
역할 에이전트는 본문을 부모 대화에 붙여넣지 않고 receipt(run_id/path/sha256/stage/stage_n/created_at)만 반환한다 — 감사 추적은 남기되 컨텍스트 중복은 막는다. 같은 영수증 사고가 skill 본문 끝의 Skill: <path> 메타 라인에도 보인다.
요약 & 셀프체크
Hooks: 코어가 생명주기 시점에 자동으로 부르는 끼어들기 콜백. 루프 안에서 돌기에 일부러 좁은 권한만 받고, 차단·수정은 반환 Result로 한다.
Skills: 카탈로그(이름+설명)만 가볍게 노출하다 필요할 때 skill://로 본문을 펼치는 2단계 지연 로딩. content 임베디드 덕에 .gjc 삭제 후에도 번들 디폴트가 산다.
Slash-Commands: 사용자가 부르는 단축키로 마크다운·TS 두 갈래. 발견 우선순위(번들 → user/project, 충돌은 에러)로 덮어쓰기를 관리한다.
스스로 답해보기
왜 HookUIContext는 에디터 컴포넌트 교체를 막고, 세션 제어를 HookCommandContext로 따로 떼어냈을까?
<skills>에 description만 싣고 본문을 빼는 2단계 구조는 어떤 비용을 아끼나? read 도구가 없으면 스킬을 아예 안 보이는 이유는?
hide: true 스킬이 목록에서 사라지는데도 실행 가능한 이유는? 어떤 두 경로로 도달하나?