지식위키

gajae-code · 확장점 형식: Hooks / Skills / Slash-Commands

gajae-code · 확장점 형식: Hooks / Skills / Slash-Commands

한 줄 요약

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

쉽게 풀기

세 확장점을 **“한 식당의 세 직원”**으로 비유하면 쉽다.

  1. Hooks = 주방 안 검수원 — 요리(에이전트 루프)가 도는 도중에 끼어든다. “이 재료(도구 호출) 빼라”, “나가기 전 간 봐라(메시지 수정)” 같은 일. 단 검수원에겐 권한을 일부러 좁게 준다. 루프 도중 화면(에디터)을 빼앗으면 흐름이 꼬이고 데드락이 나기 때문. 그래서 받는 UI는 “선택지·확인·알림” 정도뿐, 에디터 교체나 테마 변경은 막혀 있다.

  2. Skills = 필요할 때만 펼치는 매뉴얼 책장SKILL.md 한 장이 전문 지식 한 묶음이다. 핵심은 2단계 지연 로딩: 평소엔 제목+한 줄 소개(name+description)만 책장에 꽂고, 모델이 필요하다 싶을 때만 skill://<이름>으로 본문(절차·규칙)을 펼친다. 가벼운 목록 → 필요 시 무거운 본문, 이 구조로 토큰을 아낀다.

  3. 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 없음"]

[!note]- 펼쳐보기: 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에만 있다.

[!note]- 펼쳐보기: 전체 Hooks 이벤트 / Skills·Slash-Commands 동작 목록 Hooks 이벤트 — 할 수 있는 일로 묶기

  • 관찰만: session_start/switch/shutdown, agent_start/end, turn_start/end, auto_compaction_*, todo_reminder
  • 차단 가능(cancel): session_before_switch/_branch/_compact/_tree
  • 메시지 갈아끼움: context(LLM 전송 직전 messages 교체), before_agent_start(영속·TUI 표시 메시지 주입)
  • 도구 게이팅: tool_call(실행 차단), tool_result(결과 수정)

Skills — hide/content 두 동작

  • hide: true<skills> 목록·슬래시 별칭에서 빠지지만 skill://·/skill:<name>으로는 여전히 도달
  • read 도구가 없으면 시스템 프롬프트에 스킬 자체를 노출 안 함(펼칠 수단이 없으니)
  • content(임베디드 본문)가 있으면 파일 대신 그걸 씀 → .gjc 삭제 후에도 번들 디폴트 작동
  • 임베디드 스킬(baseDirembedded:)은 상대 자산 파일을 못 가짐

Slash-Commands — 발견 우선순위

  • ① 번들 기본(green/review 등) 먼저 로드 (최저 우선순위)
  • ② user/project commands/*/index.{ts,js,mjs,cjs} → 번들을 덮어쓸 수 있음
  • user ↔ project끼리 같은 이름이면 충돌 에러 (번들 덮어쓰기만 허용)

실제 예시

1) Hooks — 좁힌 UI 표면 + 이벤트 타입

핵심만: 이벤트 핸들러는 HookHandler<E,R> = (event, ctx) => Promise<R|void>|R|void이고 pi.on(event, handler)로 구독한다. 차단·수정은 항상 핸들러의 반환 Result로 한다(예: tool_call에서 {block:true} 반환). 훅 모듈은 default export 함수(HookFactory).

[!note]- 펼쳐보기: 전체 타입 — HookUIContext / ToolCallEvent

// packages/coding-agent/src/extensibility/hooks/types.ts
// hooks는 ExtensionUIContext보다 좁은 UI — 터미널 입력 리스너·에디터 컴포넌트
// 오버라이드·테마 관리 없음. 루프 안에서 호출돼 에디터 소유권을 못 빼앗게.
export interface HookUIContext {
	select(title: string, options: string[]): Promise<string | undefined>;
	confirm(title: string, message: string): Promise<boolean>;
	input(title: string, placeholder?: string): Promise<string | undefined>;
	notify(message: string, type?: "info" | "warning" | "error"): void;
	setStatus(key: string, text: string | undefined): void;
	setEditorText(text: string): void;   // "텍스트"만, 컴포넌트 교체 불가
	getEditorText(): string;
	readonly theme: Theme;
}

export interface ToolCallEvent {
	type: "tool_call";
	toolName: string;        // "bash" | "edit" | "write" ...
	toolCallId: string;
	input: Record<string, unknown>;
}
export type HookFactory = (pi: HookAPI) => void;  // default export 함수

내가 직접 만들 때 (Hook 템플릿)

// .gjc/hooks/guard.ts
import type { HookFactory } from "@gajae-code/coding-agent";
const hook: HookFactory = (pi) => {
  pi.on("tool_call", async (event, ctx) => {
    if (event.toolName === "bash" && String(event.input.command).includes("rm -rf /")) {
      ctx.ui.notify("위험 명령 차단", "error");
      return { block: true, reason: "destructive command" }; // ToolCallEventResult
    }
  });
  pi.on("before_agent_start", async () => ({
    message: { customType: "guard", content: `cwd=${process.cwd()}`, display: false, attribution: "agent" },
  }));
};
export default hook;
  • default export 함수인가
  • UI는 좁은 표면만 (에디터 컴포넌트 교체·테마 변경 금지)
  • 차단/수정은 핸들러 반환 Result
  • 세션 제어는 명령 핸들러(HookCommandContext)에서만

2) Skills — SKILL.md + skill:// + 임베디드 본문

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["&lt;skills&gt; 목록<br/>name+description만"]
  LIST -->|모델 매칭| RES["skill:// 해석"]
  H -->|명시 호출| RES
  RES --> C{"content 임베디드?"}
  C -->|있음| EMB["그 본문 사용<br/>.gjc 삭제에도 생존"]
  C -->|없음| FILE["파일 읽고<br/>frontmatter 제거 + Skill 메타"]

[!note]- 펼쳐보기: 전체 코드 — SKILL.md / 리스팅 템플릿 / hide 필터 / skill:// / 본문 주입

<!-- packages/coding-agent/src/defaults/gjc/skills/ralplan/SKILL.md -->
---
name: ralplan
description: Consensus planning entrypoint that auto-gates vague team/ultragoal requests before execution
argument-hint: "[--interactive] [--deliberate] [--architect openai-code] [--critic openai-code] <task description>"
level: 4
source: "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}`];

[!note]- 펼쳐보기: 런타임 Skill 객체 핵심 필드 필수: name/description/filePath/baseDir/source("<provider>:<level>", 예 native:project). 선택: hide(목록·별칭 숨김), _source(표시용), content(.gjc 삭제에도 사는 임베디드 본문). baseDirfilePath에서 /SKILL.md를 뗀 상대자산 루트.

내가 직접 만들 때 (Skill 템플릿)

---
name: my-skill
description: 무엇을 언제 쓰는지 한 줄 (모델이 이걸로 매칭)
argument-hint: "<file> [--dry-run]"
level: 3
---

# My Skill
1. 절차...
참고 자산은 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").
  • (b) TS 커스텀 커맨드 (custom-commands/): 필드 name(네임스페이스 git:commit 가능)/description/execute(string 반환=LLM 프롬프트, void=조용히 종료). 팩토리는 default export 함수, CustomCommandAPI(cwd/exec/typebox/zod/pi) 주입.
  • (c) builtin-registry: 선언적 정의로 /provider·/login 같은 코어 명령을 직접 처리. subcommands로 드롭다운 자동완성·인라인 힌트 생성.

[!note]- 펼쳐보기: 전체 코드 — 로더 우선순위 / 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.ts
export interface BuiltinSlashCommand {
	name: string;
	description: string;
	subcommands?: SubcommandDef[]; // /mcp add, /mcp list 같은 드롭다운
	inlineHint?: string;
}

내가 직접 만들 때 (TS 커스텀 커맨드 템플릿)

// .gjc/commands/deploy/index.ts
import 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/&lt;run-id&gt;/ 영속"]
  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, 충돌은 에러)로 덮어쓰기를 관리한다.

[!question] 스스로 답해보기

  1. HookUIContext는 에디터 컴포넌트 교체를 막고, 세션 제어를 HookCommandContext로 따로 떼어냈을까?
  2. <skills>에 description만 싣고 본문을 빼는 2단계 구조는 어떤 비용을 아끼나? read 도구가 없으면 스킬을 아예 안 보이는 이유는?
  3. hide: true 스킬이 목록에서 사라지는데도 실행 가능한 이유는? 어떤 두 경로로 도달하나?

근거 파일

[!note]- 펼쳐보기: 전체 근거 파일 목록

  • packages/coding-agent/src/extensibility/hooks/types.ts (HookUIContext, HookContext, HookEvent 유니온, HookAPI, HookFactory)
  • packages/coding-agent/src/extensibility/hooks/loader.ts (createHookAPI, loadHook/loadHooks, discoverAndLoadHooks)
  • packages/coding-agent/src/extensibility/skills.ts (Skill 인터페이스·hide·content, loadSkills, resolveSkillSlashCommands, buildSkillPromptMessage)
  • packages/coding-agent/src/defaults/gjc/skills/ralplan/SKILL.md (frontmatter 실측, pre-execution gate, RECEIPT-ONLY)
  • packages/coding-agent/src/extensibility/slash-commands.ts (FileSlashCommand, BUILTIN_SLASH_COMMANDS, loadSlashCommands, expandSlashCommand, EMBEDDED 템플릿)
  • packages/coding-agent/src/extensibility/custom-commands/loader.ts (discover/loadCustomCommands, 번들→user/project 우선순위·override)
  • packages/coding-agent/src/extensibility/custom-commands/types.ts (CustomCommand, CustomCommandAPI, CustomCommandFactory)
  • packages/coding-agent/src/slash-commands/types.ts (BuiltinSlashCommand, SubcommandDef)
  • packages/coding-agent/src/slash-commands/builtin-registry.ts (BUILTIN_SLASH_COMMAND_DEFS, executeBuiltinSlashCommand)
  • packages/coding-agent/src/prompts/system/custom-system-prompt.md (<skills> 리스팅 템플릿)
  • packages/coding-agent/src/system-prompt.ts (hide 필터, read 도구 게이팅)
  • packages/coding-agent/src/internal-urls/skill-protocol.ts (skill:// 해석, embedded 자산 제한)
  • packages/coding-agent/src/capability/skill.ts (CapabilitySkill.content/frontmatter)

연결

GJ_개요 · _분석축_루브릭