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.json | MCP 도구 서버 등록(t) | mcpServers.t.command/args |
hooks/hooks.json | 이벤트 → 스크립트 매핑(진입점 표) | <EventName>[].matcher, .hooks[] |
hooks.json 한 항목과 결정 JSON의 핵심 필드만 추리면.
| 구분 | 필드 | 의미 |
|---|---|---|
| 입력(hooks.json) | matcher | 필터(*=모두, Bash, init/maintenance) |
| 입력 | hooks[].command/timeout | node 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[].commandO node 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, 3SessionStart(*): session-start.mjs → project-memory-session.mjs → wiki-session-start.mjs · 5,5,5SessionStart(init): setup-init.mjs · 30 / (maintenance): setup-maintenance.mjs · 60PreToolUse(*): pre-tool-enforcer.mjs · 3PermissionRequest(Bash): permission-handler.mjs · 5PostToolUse(*): post-tool-verifier.mjs → project-memory-posttool.mjs → post-tool-rules-injector.mjs · 3,3,3PostToolUseFailure(*): post-tool-use-failure.mjs · 3SubagentStart(*): subagent-tracker.mjs start · 3 /SubagentStop(*): subagent-tracker.mjs stop → verify-deliverables.mjs · 5,5PreCompact(*): pre-compact.mjs → project-memory-precompact.mjs → wiki-pre-compact.mjs · 10,5,3Stop(*): context-guard-stop.mjs → persistent-mode.mjs → code-simplifier.mjs · 5,10,5SessionEnd(*): 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.json에name/version존재(마켓플레이스 설치하려면marketplace.json도)hooks/hooks.json이 플러그인 루트hooks/아래에 있다(CC 관례 경로)- 모든
command가node "$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줄 요약:
- OMC는 실행 루프가 없는 플러그인이고, 루프는 전부 Claude Code 본체가 돈다.
- OMC는 생명주기 이벤트마다 stdin으로 정보를 받아 stdout으로 결정 JSON 한 줄을 뱉을 뿐이다.
- 만들 것은 매니페스트 + 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 킬스위치가 정의돼 있다.)