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

실제 예시

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).

내가 직접 만들 때 (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 메타"]

내가 직접 만들 때 (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로 드롭다운 자동완성·인라인 힌트 생성.

내가 직접 만들 때 (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, 충돌은 에러)로 덮어쓰기를 관리한다.

스스로 답해보기

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

근거 파일

연결

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