지식위키

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

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가 한다.

[!note] 그래서 만들 것은 딱 셋 (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")

[!note]- 펼쳐보기: 전체 필드표 (hooks.json 스키마 + 출력 필드) hooks.json 한 항목의 스키마

필드필수설명
<EventName>O이벤트 이름별 등록 목록(한 이벤트에 매처 여러 개 가능)
[].matcherO하위 필터. "*"=모두, "Bash"=Bash만, "init"/"maintenance"=SessionStart의 source 분기
[].hooks[].typeO항상 "command"(CC가 셸 명령으로 실행)
[].hooks[].commandOnode run.cjs <hook>.mjs 패턴 고정
[].hooks[].timeoutO초 단위 타임아웃(run.cjs가 ms로 환산)

스크립트가 뱉는 결정 JSON의 주요 출력 필드

출력 필드의미
continue루프 계속 진행 여부(보통 true)
suppressOutput훅 출력 숨김(주입할 것 없을 때)
hookSpecificOutput.additionalContext모델 컨텍스트에 추가 주입할 텍스트
hookSpecificOutput.permissionDecision도구 호출 허용/차단("deny" 등)
hookSpecificOutput.updatedInput도구 입력 자체를 교체(Task 라우팅 등)

[!note]- 펼쳐보기: OMC가 실제로 후킹하는 이벤트 전수 (hooks.json)

  • UserPromptSubmit (*): keyword-detector.mjs → skill-injector.mjs · timeout 5, 3
  • SessionStart (*): session-start.mjs → project-memory-session.mjs → wiki-session-start.mjs · 5,5,5
  • SessionStart (init): setup-init.mjs · 30 / (maintenance): setup-maintenance.mjs · 60
  • PreToolUse (*): pre-tool-enforcer.mjs · 3
  • PermissionRequest (Bash): permission-handler.mjs · 5
  • PostToolUse (*): post-tool-verifier.mjs → project-memory-posttool.mjs → post-tool-rules-injector.mjs · 3,3,3
  • PostToolUseFailure (*): post-tool-use-failure.mjs · 3
  • SubagentStart (*): subagent-tracker.mjs start · 3 / SubagentStop (*): subagent-tracker.mjs stop → verify-deliverables.mjs · 5,5
  • PreCompact (*): pre-compact.mjs → project-memory-precompact.mjs → wiki-pre-compact.mjs · 10,5,3
  • Stop (*): context-guard-stop.mjs → persistent-mode.mjs → code-simplifier.mjs · 5,10,5
  • SessionEnd (*): session-end.mjs (async) → wiki-session-end.mjs (async) · 30,30

왜 모든 명령이 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
  }
}));

[!note]- 펼쳐보기: 전체 코드 (hooks.json SessionStart 분기 · run.cjs · 키워드 주입 페이로드) hooks.json의 SessionStart 분기 — matcher(*/init/maintenance)와 node run.cjs <script> 패턴.

// hooks/hooks.json
"SessionStart": [
  {
    "matcher": "*",                                     // 모든 세션 시작에서 실행
    "hooks": [
      { "type": "command",
        "command": "node \"$CLAUDE_PLUGIN_ROOT\"/scripts/run.cjs \"$CLAUDE_PLUGIN_ROOT\"/scripts/session-start.mjs",
        "timeout": 5 },                                 // 초 단위
      { "type": "command",
        "command": "node \"$CLAUDE_PLUGIN_ROOT\"/scripts/run.cjs \"$CLAUDE_PLUGIN_ROOT\"/scripts/project-memory-session.mjs",
        "timeout": 5 }
    ]
  },
  {
    "matcher": "init",                                  // source=init (최초 설치/초기화)
    "hooks": [
      { "type": "command",
        "command": "node \"$CLAUDE_PLUGIN_ROOT\"/scripts/run.cjs \"$CLAUDE_PLUGIN_ROOT\"/scripts/setup-init.mjs",
        "timeout": 30 }
    ]
  },
  {
    "matcher": "maintenance",                           // source=maintenance (유지보수 패스)
    "hooks": [
      { "type": "command",
        "command": "node \"$CLAUDE_PLUGIN_ROOT\"/scripts/run.cjs \"$CLAUDE_PLUGIN_ROOT\"/scripts/setup-maintenance.mjs",
        "timeout": 60 }
    ]
  }
]

공통 런처 run.cjs — 인자가 없거나 스크립트를 못 찾아도 깨끗이 종료(fail-open)해 훅이 루프를 절대 막지 않는다.

// scripts/run.cjs
const target = process.argv[2];                 // 실행할 .mjs 경로
if (!target) process.exit(0);                    // 인자 없으면 종료 → 훅 안 막음
const resolved = resolveTarget(target);          // 존재 확인/symlink/캐시 스캔 fallback
if (!resolved) process.exit(0);                  // 못 찾아도 fail-open
const timeoutMs = resolveHookTimeoutMs(resolved, process.argv.slice(3)); // 초→ms
const result = spawnSync(process.execPath, [resolved, ...process.argv.slice(3)], {
  stdio: 'inherit',                              // stdin/stdout/stderr 직통 (= CC와 연결)
  env: process.env, windowsHide: true,
  ...(timeoutMs ? { timeout: timeoutMs, killSignal: ... } : {}),
});
process.exit(result.status ?? 0);                // 종료코드 전파 (null→0, 즉 막지 않음)

키워드 주입 페이로드 — 사용자가 ralph를 친 순간 스킬 발동 지시가 컨텍스트로 주입된다.

// keyword-detector.mjs
return `[MAGIC KEYWORD: ${skillName.toUpperCase()}]

Skill routing detected: ${skillName}
Preferred invocation: /oh-my-claudecode:${skillName}${args ? ` ${args}` : ''}
${pathStatus}${argsSection}

User request (compact echo; original prompt remains authoritative):
${compactHookText(originalPrompt)}

IMPORTANT: Start the ${skillName} workflow immediately. ...`;

직접 만들 때 최소 플러그인 = 매니페스트 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 })); // 주입할 것 없음
}

[!note]- 펼쳐보기: 직접 만들 때 체크리스트

  • .claude-plugin/plugin.jsonname/version 존재(마켓플레이스 설치하려면 marketplace.json도)
  • hooks/hooks.json이 플러그인 루트 hooks/ 아래에 있다(CC 관례 경로)
  • 모든 commandnode "$CLAUDE_PLUGIN_ROOT"/... 절대경로 패턴 — 상대경로 금지
  • timeout초 단위 숫자(35초 권장, 무거운 setup만 3060)
  • 스크립트는 항상 stdout에 JSON 한 줄을 뱉고 종료, 실패해도 {continue:true, suppressOutput:true}로 fail-open
  • 컨텍스트 주입은 hookSpecificOutput.additionalContext, 도구 차단은 permissionDecision:'deny', message 필드는 무효
  • (선택) run.cjs 같은 런처로 Windows/stale-path/timeout 환산을 흡수
  • 새 에이전트 loop를 짜려 들지 말 것 — 루프는 CC가 돌린다, 이벤트 후킹이 전부

요약 & 셀프체크

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

[!tip] 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 킬스위치가 정의돼 있다.)