OMC (oh-my-claudecode) · 진입점과 훅 실행 루프 (Claude Code 플러그인으로서의 OMC)

한 줄 요약

OMC는 자기 실행 루프가 없다. Claude Code 본체 루프의 “정해진 순간(생명주기 이벤트)“마다 끼어들어 컨텍스트를 주입하거나 도구를 통제하는 플러그인이다. — 왜 배우나: 하네스를 만들 때 “루프를 새로 짜야 한다”는 오해를 버리고, 이벤트에 작은 스크립트만 꽂으면 된다는 핵심 설계를 잡기 위해서다.


그림

OMC가 직접 루프를 돌리는 게 아니라, CC 루프 중간에 “이벤트가 울릴 때만” 호출된다.

flowchart LR
  U[사용자 메시지 전송] --> CC[Claude Code 본체 루프]
  CC -->|"이벤트 발생 시<br/>stdin: JSON 페이로드"| RUN[run.cjs 공통 런처]
  RUN -->|".mjs 스크립트 실행"| KD["훅 스크립트<br/>(keyword-detector.mjs 등)"]
  KD -->|"stdout: 결정 JSON 한 줄"| CC
  CC -->|"additionalContext 합침"| M[모델 호출]
  M -->|"응답·도구 사용 결정"| CC
  CC -->|"루프 계속<br/>(다음 이벤트로)"| CC

생명주기 이벤트가 한 세션 동안 울리는 순서.

sequenceDiagram
  participant CC as Claude Code 루프
  participant H as OMC 훅 스크립트
  CC->>H: SessionStart (세션 시작)
  CC->>H: UserPromptSubmit (사용자 메시지)
  CC->>H: PreToolUse (도구 쓰기 직전)
  CC->>H: PostToolUse (도구 쓴 직후)
  CC->>H: Stop (응답 끝낼 때)
  CC->>H: SessionEnd (세션 종료, 비동기)
  Note over CC,H: 매 시점마다 OMC는 JSON 한 줄 뱉고 종료. 루프 제어는 CC가 함.

쉽게 풀기

비유: 공항 보안검색대. 운항 전체 흐름은 공항(=Claude Code)이 돌린다. OMC는 길목에 선 검색 요원이다. 승객이 들어올 때(이벤트), 짐을 들고 게이트로 갈 때(또 다른 이벤트)마다 잠깐 멈춰 세워 “이 안내문을 더 들려줘라”(컨텍스트 주입)거나 “이 짐은 막아라”(도구 차단) 하고 쪽지(JSON) 한 장을 건넨 뒤 끝낸다. 비행기를 띄우거나 다시 부르는 것은 어디까지나 공항이다.

핵심 흐름을 한눈에 본다.

flowchart TD
  E[이벤트 발생] --> S["OMC 스크립트 호출<br/>(stdin: 이벤트 정보)"]
  S --> O["stdout: 결정 JSON 한 줄<br/>additionalContext / permissionDecision"]
  O --> X[스크립트 종료]
  X --> CC["나머지는 CC가 처리<br/>(모델 재호출·도구 우회·루프 진행)"]

요약하면: ①OMC는 루프가 없다 ②하는 일은 “끼어들기 지점 등록” ③이벤트가 울리면 CC가 스크립트를 stdin과 함께 불러준다 ④스크립트는 stdout으로 JSON 한 줄(additionalContext 주입 / permissionDecision: deny 차단)을 뱉는다 ⑤모델 재호출·도구 우회 등 나머지는 CC가 한다.

그래서 만들 것은 딱 셋

(1) 플러그인 매니페스트, (2) “어떤 이벤트에 어떤 스크립트를 꽂을지” 적은 hooks.json, (3) stdin을 읽고 stdout으로 JSON을 뱉는 스크립트. OMC의 “진입점”은 단 하나의 main()이 아니라, hooks.json에 나열된 여러 이벤트 진입점의 묶음이다.


핵심 정리

플러그인으로 인식되는 데 필요한 파일 묶음.

파일역할핵심 필드
.claude-plugin/plugin.json플러그인 본체 매니페스트name, version, skills[], mcpServers
.claude-plugin/marketplace.json설치 카탈로그 항목plugins[].source:"./"
.mcp.jsonMCP 도구 서버 등록(t)mcpServers.t.command/args
hooks/hooks.json이벤트 → 스크립트 매핑(진입점 표)<EventName>[].matcher, .hooks[]

hooks.json 한 항목과 결정 JSON의 핵심 필드만 추리면.

구분필드의미
입력(hooks.json)matcher필터(*=모두, Bash, init/maintenance)
입력hooks[].command/timeoutnode run.cjs <hook>.mjs 고정 · 타임아웃 초 단위
출력(결정 JSON)additionalContext모델에 추가 주입할 텍스트
출력permissionDecision도구 호출 허용/차단("deny")

왜 모든 명령이 run.cjs를 거치나 — 공통 런처를 경유하는 이유 셋: ①크로스플랫폼(Windows에서 /bin/sh 없이 Node가 직접 .mjs 실행) ②CLAUDE_PLUGIN_ROOT가 낡았을 때(stale) 캐시에서 최신 스크립트를 찾아 fail-open ③timeout(초)을 읽어 ms로 환산.


실제 예시

플러그인 매니페스트와 MCP 서버 등록. plugin.json은 hooks를 직접 안 적는다 — CC가 플러그인 루트의 hooks/hooks.json을 관례적으로 읽는다.

// .claude-plugin/plugin.json
{
  "name": "oh-my-claudecode",
  "version": "4.14.7",
  "skills": [ "./skills/ai-slop-cleaner/", "./skills/ask/", "..." ],
  "mcpServers": "./.mcp.json",   // 도구 서버 등록을 별도 파일로 위임
  "commands": "./commands/"       // /명령 28종 디렉터리
}
// .mcp.json
{
  "mcpServers": {
    "t": {                        // 모든 OMC MCP 도구의 네임스페이스 접두사
      "command": "node",
      "args": ["${CLAUDE_PLUGIN_ROOT}/bridge/mcp-server.cjs"]
    }
  }
}

핵심 두 가지만 본문에 둔다 — (A) 컨텍스트 주입은 반드시 hookSpecificOutput.additionalContext(코드 주석에 박혀 있음), (B) 도구 차단은 permissionDecision: 'deny'.

// keyword-detector.mjs — (A) 'message' 필드는 무효, additionalContext를 써야 함
function createHookOutput(additionalContext) {
  return {
    continue: true,
    hookSpecificOutput: {
      hookEventName: 'UserPromptSubmit',
      additionalContext            // ← 이 텍스트가 모델 컨텍스트로 주입됨
    }
  };
}
// pre-tool-enforcer.mjs — (B) 모델 라우팅 가드: 도구 호출 차단
console.log(JSON.stringify({
  continue: true,
  hookSpecificOutput: {
    hookEventName: 'PreToolUse',
    permissionDecision: 'deny',            // ← 이 도구 호출을 차단
    permissionDecisionReason: ultragoalDenyReason
  }
}));

직접 만들 때 최소 플러그인 = 매니페스트 1개 + hooks.json 1개 + 스크립트 1개. 세 파일의 관계.

flowchart LR
  P["plugin.json<br/>(name·version)"] -.등록.-> CC[Claude Code]
  CC -.관례 경로로 읽음.-> H["hooks/hooks.json<br/>(이벤트→스크립트 표)"]
  H -->|UserPromptSubmit| K["scripts/keyword.mjs<br/>(stdin→stdout JSON)"]
// .claude-plugin/plugin.json  (플러그인 등록)
{ "name": "my-mini-omc", "version": "0.1.0", "description": "minimal hook plugin" }
// hooks/hooks.json  (이벤트 진입점 표)
{
  "hooks": {
    "UserPromptSubmit": [
      { "matcher": "*",
        "hooks": [
          { "type": "command",
            "command": "node \"$CLAUDE_PLUGIN_ROOT\"/scripts/keyword.mjs",
            "timeout": 5 }
        ]
      }
    ]
  }
}
// scripts/keyword.mjs  (stdin → stdout JSON 계약)
import { readFileSync } from 'fs';
let input = '';
try { input = readFileSync(0, 'utf-8'); } catch {}   // fd 0 = stdin
let data = {}; try { data = JSON.parse(input); } catch {}
const prompt = data.prompt || data.user_prompt || '';
 
if (/\bralph\b/i.test(prompt)) {
  console.log(JSON.stringify({
    continue: true,
    hookSpecificOutput: {
      hookEventName: 'UserPromptSubmit',
      additionalContext: '[MAGIC KEYWORD: RALPH]\nStart the ralph workflow immediately.'
    }
  }));
} else {
  console.log(JSON.stringify({ continue: true, suppressOutput: true })); // 주입할 것 없음
}

요약 & 셀프체크

3줄 요약:

  1. OMC는 실행 루프가 없는 플러그인이고, 루프는 전부 Claude Code 본체가 돈다.
  2. OMC는 생명주기 이벤트마다 stdin으로 정보를 받아 stdout으로 결정 JSON 한 줄을 뱉을 뿐이다.
  3. 만들 것은 매니페스트 + hooks.json + 스크립트 셋, 컨텍스트 주입은 additionalContext·도구 차단은 permissionDecision:'deny'.

스스로 답해보기:

  • 사용자가 메시지를 보낸 뒤 모델을 “다시 호출”하는 주체는 OMC인가, Claude Code인가? 그 이유는?
  • 컨텍스트에 텍스트를 주입하려면 출력 JSON의 어떤 필드를 써야 하며, message 필드를 쓰면 왜 안 되는가?
  • 훅 스크립트가 에러로 죽었을 때 사용자의 작업이 멈추지 않으려면 어떤 출력으로 종료해야 하는가(fail-open)?

연결

OMC_개요 · _분석축_루브릭 · OMC_20_prompt-assembly-claudemd · OMC_60_guardrails-permissions

Codex 교차검증 보존

원문 노트에는 별도의 Codex 교차검증 섹션이 없었다. 추후 교차검증 결과가 추가되면 이 콜아웃에 누적 보존한다. (근거 파일은 본문 코드블록 주석에 명시된 경로 그대로: hooks.json, plugin.json, marketplace.json, .mcp.json, run.cjs, keyword-detector.mjs, session-start.mjs, pre-tool-enforcer.mjs, bridge/mcp-server.cjs, CLAUDE.md — 모두 /home/seunghyeong/harness-work/oh-my-claudecode/ 하위. CLAUDE.md에는 MAGIC KEYWORD 규약과 DISABLE_OMC/OMC_SKIP_HOOKS 킬스위치가 정의돼 있다.)