글 검색

제목으로 글을 찾습니다

AI에게 부탁하는 건 규칙이 아니다

2026-06-17#ai#claude-code#harness

세션마다 주입한 안내문이 9일간 한 번도 실행되지 않았다. 부탁을 Stop hook 강제로 바꾼 기록.

하네스에는 주간 메모리 정리 태스크가 있다. 세션이 쌓이며 생기는 오래된 메모리 파일을 일주일에 한 번 점검·정리하는 일인데, 이걸 AI에게 맡기려고 세션 시작 훅에 안내문을 넣어뒀다. "마지막 정리로부터 7일이 지났습니다. 정리를 실행해 주세요." 9일 뒤 확인해보니 실행 횟수는 0회였다.

왜 내 부탁 무시해?#

Claude Code의 SessionStart 훅은 텍스트(additionalContext)를 컨텍스트에 주입할 수 있을 뿐, 행동을 강제하는 장치가 아니다. 모델에게 이 텍스트는 사용자의 실제 요청과 경쟁하는 배경 정보이고, 이 경쟁에서 배경 정보는 항상 진다. 사용자가 "이 버그 고쳐줘"로 시작한 세션에서 모델이 하던 일을 멈추고 메모리 정리부터 하는 일은 없었다. 단 한 번도.

더 뼈아픈 건 이게 처음이 아니었다는 점이다. 며칠 전 회고에서 이미 "세션 시작이 유일한 트리거인데 강제력이 없다"고 진단해놓고, 안내문 문구를 다듬는 것으로 대응했다가 같은 실패를 반복했다. 문구의 문제가 아니라 구조의 문제였다 — 프롬프트를 어떻게 고쳐도 '부탁'이라는 형식 자체는 바뀌지 않는다.

유일한 강제 지점, Stop hook#

Claude Code의 훅 중 모델의 행동을 결정론적으로 강제할 수 있는 건 Stop hook 하나다. decision: "block"을 반환하면 세션 종료가 차단되고, reason에 담은 프롬프트가 모델에게 전달된다. 모델은 그 일을 처리하기 전까지 세션을 끝낼 수 없다.

이걸로 게이트를 만들었다. 정리 스킬의 이름이 dream이라서 Dream Gate라고 부른다. 작동 흐름은 넷이다.

  1. 세션 시작 훅: 7일 경과를 감지하면 .dream-pending 플래그 파일만 만든다. 안내문은 출력하지 않는다.
  2. 세션 진행: 아무 간섭 없음.
  3. 세션 종료 시 Stop hook: 플래그가 있으면 즉시 삭제하고 종료를 1회 차단, 정리 실행 프롬프트를 주입한다.
  4. 모델이 정리를 백그라운드 에이전트로 실행하고 세션을 끝낸다.

Stop hook 스크립트 전문이다. 26줄이면 된다.

#!/bin/bash
# dream-gate-stop.sh
# Stop hook: 메모리 hygiene 미실행 시 세션 종료 1회 차단
# One-shot: 플래그 즉시 삭제로 무한 루프 방지
# Context-Efficient: 플래그 없으면 즉시 exit 0 (오버헤드 0)
 
DREAM_PENDING="$HOME/.claude/projects/my-project/.dream-pending"
 
# 플래그 없으면 즉시 통과
[ -f "$DREAM_PENDING" ] || exit 0
 
# 플래그 즉시 삭제 (one-shot 보장 — 무한 루프 방지)
rm -f "$DREAM_PENDING"
 
# 타임스탬프는 여기서 기록하지 않음
# dream SKILL.md 완료 단계에서 기록 (dream 실패 시 재트리거 보장)
 
# 세션 종료 차단 + dream 실행 프롬프트 주입
jq -n '{
  "decision": "block",
  "reason": "주간 Memory Hygiene 시간입니다. 세션 종료 전에 실행합니다.\n\nAgent(general-purpose, run_in_background=true)를 사용하여 /harness:dream 4단계 프로세스를 실행하세요.\nDream 완료 후 반드시 .dream-last-run 타임스탬프를 epoch로 기록하세요 — 정확히 `date +%s > \"$HOME/.claude/projects/my-project/memory/.dream-last-run\"` 명령만 사용. ISO 문자열로 쓰면 staleness-check hook 산술이 깨집니다.\n실행 후 사용자에게 \"Memory Hygiene 백그라운드 실행 중\" 한 줄 알림하고 세션을 종료하세요.",
  "systemMessage": "Dream gate: weekly dream due. Run dream then stop."
}'
 
exit 0

설계 결정은 둘이다.

  • one-shot 보장 — block은 "이번 종료"만 막는 게 아니라 플래그가 남아 있는 한 다음 종료도 계속 막는다. 무한 루프다. 그래서 차단을 결정한 직후 플래그부터 삭제한다.
  • 세션 중 간섭 제로 — 강제 장치를 넣었으면 부탁은 치운다. 기존 안내문 주입을 완전히 제거했다. 세션 중에는 아무것도 보이지 않고, 종료 시점에 딱 한 번 발동한다.

그럼 전부 훅으로 강제하면 되나#

아니다. 넛지(md 규칙)와 훅의 트레이드오프를 실측해보면 이렇다.

넛지 (md 규칙)
발동률~90% (확률적)100% (결정론적)
상황 판단100% (맥락 적응)0% (기계적)

훅은 반드시 발동하는 대신 판단하지 않는다. 판단이 필요한 규칙까지 훅으로 옮기면 단순 작업에도 무거운 절차가 기계적으로 걸린다. 그래서 기준은 이렇게 잡았다 — 상황을 봐야 하는 규칙은 md 넛지로 남기고, 한 번이라도 어기면 안 되는 것만 훅으로 옮긴다. 주간 정리는 후자였다. "이번 세션에선 안 해도 되는" 경우가 없기 때문이다.

훅 안에도 부탁이 숨어 있었다#

두 달쯤 뒤 점검에서 dream이 35일간 실행되지 않은 걸 발견했다. 이번엔 훅 체인이 문제였다. 마지막 실행 시각을 기록하는 .dream-last-run 파일을 읽는 쪽은 epoch 정수를 기대하는데, 파일에는 ISO 문자열(2026-05-05T01:19:56Z)이 들어 있어 날짜 계산이 깨졌고, 7일 경과 판정이 조용히 실패하고 있었다.

원인은 스크립트 안의 지시문이었다. 당시 프롬프트가 "타임스탬프를 기록하세요"라고만 적혀 있어서, 기록을 수행하는 모델이 ISO 형식으로 썼다. 강제 장치 안에도 부탁이 숨어 있었던 것이다. 읽는 쪽은 epoch와 ISO 양쪽을 파싱하도록 방어하고, 쓰는 쪽 지시문에는 date +%s 명령을 그대로 박았다. 위 스크립트의 "ISO 문자열로 쓰면 산술이 깨집니다" 경고가 이 사건의 흔적이다.

정리#

AI에게 시키는 일은 두 부류다. 상황 판단이 필요한 일은 md 규칙으로 적고 확률적 발동을 받아들인다. 무조건 실행돼야 하는 일을 산문으로 적어두는 건 규칙을 만든 게 아니라 부탁을 적은 것이다 — 그건 훅으로 강제한다. 그리고 훅도 만들었다고 끝이 아니다. 실제로 발동하는지, 훅이 주고받는 값의 형식까지 지켜지는지 주기적으로 측정해야 한다.

참고: Claude Code 공식 문서 — Hooks · 하네스 엔지니어링 입문 (위키독스)