About

에이전트에게 부탁하지 말고 훅으로 강제하라 — oh-my-openagent 코드를 읽고 내 하네스에 가져온 것

실패 하나에 훅 하나를 붙여 57개까지 쌓은 하네스, 그리고 훅끼리 부딪혀 결국 문을 하나로 모은 이야기

결론부터. 에이전트가 망가지는 지점이 보이면 프롬프트에 한 줄 더 쓰지 말고, 그 지점에 훅(정해진 순간마다 도는 코드)을 건다. 이 글을 읽으면 OpenCode 플러그인 oh-my-openagent(OmO)가 실제로 어떤 훅을 거는지 알고, 같은 생각을 Claude Code 에서 어떻게 써먹는지 예제로 확인할 수 있다. 훅이 너무 늘면 생기는 문제도 같이 적었다.

OmO 는 「배터리 포함 에이전트 하네스」 라고 스스로를 소개한다. 하네스는 모델을 감싸서 도구 · 규칙 · 재시도를 붙이는 바깥 껍데기다. 말은 좋은데 코드로 뭘 하는지 궁금해서 리포를 받아 읽었다. 기준은 2026-11-05 의 dev 브랜치(커밋 48ed2357d, 버전 5.1.60)다. 패키지 이름은 아직 oh-my-opencode 로 남아 있다.

규모부터 — 한 달에 릴리스 44번

git ls-files 와 wc -l 로 직접 셌다.

workspaces (플랫폼별 바이너리 포함)   68
TypeScript (테스트 제외)             약 49만 줄
Rust                                 약 8.6만 줄
테스트 파일                          4,139
설정 스키마에 이름이 있는 훅          57
릴리스 태그                          v5.1.17 (10-05) → v5.1.60 (11-04), 44번

OpenCode 를 포크하지 않는다. OpenCode 가 열어 둔 훅 지점(설정 로드 · 메시지 받음 · 도구 실행 전후 · 세션 이벤트 · 압축)에 끼어들어 동작을 바꾼다. 그래서 OpenCode 가 올라가도 따라가기 쉽다. 대신 훅 지점이 안 열린 곳은 못 건드린다.

Ask in the prompt, or enforce in a hookAskAgentPrompt: re-read before editing,don't stop with todos leftfollowed most of the timenobody checks the restEnforceAgentHooks (code, runs every time)Read: tag each line LINE#IDEdit: hash differs, rejectIdle with todos left: push onTools / sessionfile system, modelThe catch: once there are dozens of hooks, two of them can see the same idle and both inject a message.Fix in OmO: every injected message goes through one gate, dispatchInternalPrompt().

실패 하나에 훅 하나

훅 목록을 읽다 보면 이름이 곧 「에이전트가 이렇게 망가졌었다」 는 기록이다. 몇 개만 골랐다.

망가지는 모습 훅 하는 일
낡은 줄 위에 편집 hashline-read-enhancer + hashline-core Read 결과 줄마다 해시를 붙이고, 해시가 다르면 편집 거부
TODO 남기고 멈춤 todo-continuation-enforcer 쉬는(idle) 순간 남은 TODO 가 있으면 「계속하라」 를 넣는다
플래너가 코드를 고침 prometheus-md-only 계획 담당 에이전트는 .md 만 편집
있는 파일을 통째로 덮어씀 write-existing-file-guard Write 로 기존 파일 덮어쓰기를 막는다
폴더 규칙을 모름 directory-agents-injector · rules-injector 그 경로의 AGENTS.md · 규칙을 자동으로 넣는다
컨텍스트가 넘침 preemptive-compaction · compaction-todo-preserver 한도 전에 압축하고 TODO 는 남긴다

Hashline — 줄 번호에 2글자 해시를 붙인다

에이전트는 파일을 읽고, 몇 턴 뒤에 그 기억으로 편집한다. 그 사이 파일이 바뀌었으면 엉뚱한 줄을 고친다. OmO 는 Read 결과의 줄마다 줄번호#해시| 를 붙인다. 해시는 줄 내용의 xxHash32 를 256 으로 나눈 나머지를 ZPMQVRWSNKTXJBYH 16글자 중 2글자로 바꾼 것이다. 리포의 구현을 bun 으로 직접 돌려 봤다.

1#KW|def total(items)
2#KQ|  items.sum(&:price)
3#PT|end

# 2번 줄만 바꾸면
1#KW|def total(items)
2#YS|  items.sum(&:amount)
3#PT|end

바뀐 줄만 해시가 달라진다. 에이전트가 2#KQ 를 고치겠다고 하면 지금 파일의 2번 줄은 YS 라서 HashlineMismatchError 로 거절되고, 에러에 새 해시가 같이 실려 다시 읽게 된다. 2글자라 256가지뿐이니 충돌은 난다. 그래서 줄 번호와 짝으로만 쓴다.

Claude Code 의 Edit 은 old_string 이 파일과 글자 그대로 맞아야 고친다. 줄 번호 대신 원문으로 위치를 잡으니 같은 문제를 다른 방식으로 막고 있는 셈이다. 줄 번호로 편집하는 도구를 직접 만든다면 Hashline 쪽이 참고가 된다.

TODO 계속 강제 — 「멈추지 않게」 보다 「멈춰야 할 때를 아는」 코드

todo-continuation-enforcer 는 세션이 쉬는 순간 남은 TODO 가 있으면 2초 카운트다운 뒤 「Incomplete tasks remain in your todo list」 로 시작하는 메시지를 넣는다. 끝났다고 우기면 「시스템이 네 완료 주장을 의심하고 있다, 다시 확인하라」 는 문장까지 들어 있다.

재밌는 건 미는 코드보다 안 미는 코드가 훨씬 많다는 점이다. idle 처리 파일에 return 으로 빠지는 곳이 25군데다. 사용자가 막 중단했으면(3초) · 백그라운드 작업이 돌고 있으면 · 사용자에게 질문을 던진 상태면 · 토큰 한도에 걸렸으면 · 방금 압축했으면 · 5초 안에 이미 밀었으면 · 연속 5번 실패했으면 · 같은 상태로 3번 멈췄으면 밀지 않는다. 처음엔 「계속하라고 넣으면 끝」 인 줄 알았는데, 실제로는 무한 루프와 사용자 무시를 막는 쪽이 본체였다.

반전 — 훅이 늘면 훅끼리 부딪힌다

2026-05 에 올라온 이슈 #4012 는 「응답 말풍선 두 개가 동시에 같은 내용을 흘린다」 였다. 원인은 훅 하나의 실수가 아니었다. OpenCode 의 session.promptAsync 는 메시지가 확실히 받아들여지기 전에 돌아온다. 그래서 같은 idle 을 본 훅 둘(예를 들면 TODO 계속 강제와 백그라운드 작업 완료 알림)이 각자 「지금 넣어도 된다」 고 판단하고 둘 다 넣었다.

고친 방법이 이 글에서 제일 가져갈 만하다. 리포의 설계 기록(ADR)에 따르면 메시지를 넣는 곳이 13군데가 넘었고, 각자 조심하는 걸로는 못 막는다고 결론 냈다. 그래서 session.prompt · promptAsync 를 부르는 코드는 prompt-async-gate.ts 한 파일에만 두고, 나머지는 전부 dispatchInternalPrompt() 를 거치게 했다. 세션 ID 마다 예약을 잡아서 먼저 잡은 쪽만 보낸다. 이 규칙을 누가 어기면 소스 파일을 읽어 검사하는 테스트(prompt-async-route-audit.test.ts)가 깨진다.

훅으로 강제하는 것도 결국 규칙이고, 규칙이 많아지면 규칙을 강제하는 장치가 또 필요해진다. 에이전트에게 부탁하지 않듯, 훅 작성자에게도 「조심해 주세요」 대신 테스트로 막았다.

그 밖에 읽다가 걸린 것: 에이전트 정렬 순서를 지키려고 전역 Array.prototype.sort · toSorted 를 바꿔 끼운다(agent-sort-shim.ts). 스스로 「파일당 200줄」 을 권장하지만 넘는 파일이 629개, 백그라운드 작업 관리자 manager.ts 는 3,416줄이다. 익명 텔레메트리는 문서상 기본으로 켜져 있고 DO_NOT_TRACK=1 로 끈다(설치해서 직접 확인하진 않았다).

Claude Code 에서 따라 하기 — Stop 훅 하나

OmO 를 통째로 들이지 않아도 생각은 가져올 수 있다. Claude Code 의 Stop 훅은 에이전트가 응답을 끝내려는 순간 돈다. JSON 으로 decision: "block" 과 이유를 내면 멈추지 않고 그 이유를 받아 이어서 일한다.

#!/usr/bin/env bash
# .claude/hooks/todo-gate.sh — TODO.md 에 [ ] 가 남아 있으면 한 번 더 밀어 준다
input=$(cat)
# 이미 훅 때문에 이어서 도는 중이면 통과 (무한 루프 방지)
[ "$(printf '%s' "$input" | jq -r .stop_hook_active)" = true ] && exit 0
left=$(grep -c '^- \[ \]' TODO.md 2>/dev/null || true)
if [ "${left:-0}" -gt 0 ]; then
  jq -n --arg n "$left" '{decision:"block", reason:("TODO.md 에 안 끝난 항목: " + $n + "개. 다음 항목을 계속하라")}'
fi

.claude/settings.json 의 hooks.Stop 에 이 스크립트를 command 로 등록하면 된다. 입력을 흉내 내서 돌려 보면 남은 게 2개일 때 block 이 나오고, stop_hook_active 가 true 이거나 다 끝났으면 아무것도 안 낸다. 공식 문서에도 stop_hook_active 를 보고 끝나지 않는 조건으로 계속 막지 말라고 적혀 있고, 연속 이어가기는 8번에서 끊긴다.

이 스크립트는 한 번만 민다. OmO 처럼 「같은 상태로 3번이면 포기」 를 하려면 남은 개수를 파일에 적어 두고 줄었는지 비교하면 된다. 어디까지 미는지가 이 훅의 설계 전부다.

이 블로그도 사실 같은 길을 걸었다. 글을 컨트롤러에 넣을 때 __END__ 뒤에 넣으면 웹에 안 보이는데, 규칙 문서에 「주의」 를 몇 번 써도 가끔 틀렸다. 지금은 Edit · Write 뒤에 도는 PostToolUse 훅이 문법 · 위치를 검사해서 에이전트에게 바로 돌려준다. 그 뒤로 이 실수는 사람이 리뷰에서 잡을 일이 없어졌다.

언제 프롬프트로, 언제 훅으로

프롬프트에 쓴다 훅으로 강제한다
맞는 규칙 취향 · 문체 · 판단이 필요한 것 기계로 참 · 거짓을 잴 수 있는 것 (파일 상태 · 경로 · 남은 TODO)
어기면 사람이 리뷰에서 잡는다 그 자리에서 거절되고 이유가 돌아간다
비용 한 줄 스크립트 + 무한 루프 · 오탐 처리
늘어나면 프롬프트가 길어져 덜 지킨다 훅끼리 부딪힌다 → 문을 하나로 모은다

하네스 엔지니어링과 프롬프트 엔지니어링의 차이를 개념으로 정리한 글도 있다. 이 글은 그 차이를 실제 코드에서 본 쪽이다.

OmO 는 그대로 들이기보다 교재로 읽기 좋다. 훅 이름 목록만 훑어도 에이전트가 어디서 망가지는지 지도가 된다. 사내 에이전트 운영에서 같은 실수가 반복되는데 어디를 훅으로 막을지 정하기 어렵다면, negabaro 에 물어봐도 된다. 지금 쓰는 규칙 문서를 같이 보고 코드로 옮길 줄부터 고른다.

핵심 개념

1

기계로 참 · 거짓을 잴 수 있는 규칙(파일 상태 · 경로 · 남은 TODO)부터 프롬프트에서 훅으로 옮긴다

2

계속 미는 훅은 안 미는 조건(중단 직후 · 질문 대기 · 같은 상태 반복)을 먼저 쓴다

3

Claude Code Stop 훅은 stop_hook_active 를 보고 끝나지 않는 조건으로 계속 막지 않는다

4

메시지를 넣는 훅이 여럿이면 넣는 문을 한 곳으로 모으고, 우회는 테스트로 막는다

사용 사례

AI 코딩 에이전트 운영에서 같은 실수가 규칙 문서로 안 고쳐질 때 OpenCode · Claude Code 용 하네스 · 플러그인을 직접 설계할 때
← AI 에게 본번 DB 를 보여줄 때 — 쓰기는 프롬프트가 아니라 DB 권한으로 막는다