hiway-kit

완료를 주장이 아니라 명령의 출력으로: 장기 루프 에이전트의 durable 완료 게이트를 설계한 기록

루프 에이전트가 스스로 '완료'를 찍게 두면 검증되지 않은 코드가 완료로 통과한다. 네이티브 Task가 세션 스코프라는 발견이 첫 설계를 폐기시킨 과정, 그리고 완료 상태를 파일 하나와 verify 명령의 raw exit-code로 옮겨 기계가 판정하게 만든 기록.

앞선 두 글은 하네스/루프 엔지니어링의 지형도(리서치 노트)와 그것을 병렬 작업의 git 격리에 실전 적용한 기록이었다. 이번 글은 그 다음 질문에 관한 것이다 — 에이전트가 여러 세션에 걸쳐 오래 도는 루프에서, “완료"를 무엇으로 판정할 것인가.

핵심 요약

루프 에이전트에게 완료 판정을 맡기면, 모델은 검증되지 않은 작업을 “완료"로 찍는다. 완료는 모델의 주장이 아니라 명령의 출력이어야 한다.

그러려면 완료 상태가 세션 경계를 넘어 살아남아야(durable) 한다. 그런데 네이티브 Task는 세션 스코프였다 — 이 발견 하나가 우리의 첫 설계를 통째로 폐기시켰다.

결론: 완료 상태를 파일 하나(checklist.json)에 두고, 각 항목의 통과 여부를 그 항목의 verify 명령이 뱉는 raw exit-code로만 판정한다. 모델은 “검증을 돌리지 않고” 통과를 찍을 수 없다.

1. 문제: 루프가 길어질수록 “완료"가 물러진다#

단발 작업에서는 완료 판정이 쉽다. 사람이 결과를 보고 승인한다. 그러나 승인된 계획을 자율로 완주하는 루프(Ralph loop 계열)에서는 사람이 매 반복을 보지 않는다. 그 순간 완료 판정의 권한은 모델에게 넘어간다.

여기서 알려진 실패 모드가 나온다. 모델은 낙관적이다 — 구현을 끝냈다는 느낌이 들면 체크리스트 항목을 completed로 찍고 다음으로 넘어간다. 검증이 실제로 통과했는지와 무관하게. 루프가 길수록, 반복이 많을수록, 이렇게 검증 없이 완료로 통과한 항목이 쌓인다. 우리 리서치가 정리한 한 문장이 이 문제의 핵심이었다.

No PASS without proof — 증거 없는 통과는 없다. 완료는 판단이 아니라 재현 가능한 명령의 출력이어야 한다.

킷 레포에는 이미 이 철학을 담은 게이트가 있었다 — verify-done.sh. 킷 레포 자신의 완료 게이트다(플러그인 설치본에는 실리지 않는다). 하지만 그것은 “지금 이 순간"의 기계 검사(린트·테스트·시크릿·문서 sync)였을 뿐, “계획된 모든 항목이 각자의 검증을 통과했는가“를 보지 못했다. 루프의 완료를 판정하려면 후자가 필요했다.

2. 첫 설계, 그리고 그것을 죽인 한 줄의 사실#

첫 설계(Option A)는 자연스러웠다. “네이티브 Task 시스템을 재사용하자. Task마다 acceptance 조건을 달고, 그걸 durable 체크리스트로 쓰면 된다.”

세 명의 적대적 리뷰어(fresh context, 작성 세션과 분리)에게 이 설계를 던졌다. 돌아온 지적이 설계를 통째로 무너뜨렸다.

네이티브 Task는 ~/.claude/tasks/<session-UUID>/에 저장된다. 세션 스코프다. 세션이 바뀌면 다음 executor는 그 Task 저장소를 읽지 못한다. 그러니 “durable 체크리스트"가 될 수 없고, verify-done.sh도 그 저장소를 읽을 수 없으니 게이트는 종이 게이트다.

당시 확인한 이 한 줄의 사실이 Option A를 죽였다. 그리고 동시에 진짜 요구사항을 드러냈다 — 완료 상태는 세션 경계를 넘어 살아남는 곳, 즉 레포 안의 디스크 파일에 있어야 한다. Anthropic이 장기 실행 에이전트 하네스에 관한 글에서 초기화 단계가 “durable project environment"를 먼저 만들어 두라고 강조하는 이유이기도 하다(우리 리서치에서는 이를 Initializer-Executor 패턴이라 불렀다). 루프의 상태는 대화(세션)가 아니라 파일 시스템에 산다.

교훈: 재사용할 수 있어 보이는 네이티브 프리미티브라도 스코프(수명)가 요구사항과 맞는지 먼저 확인하라. “세션 스코프 vs 크로스세션 durable"의 차이가 설계의 생사를 갈랐다.

3. 설계: 완료 상태를 파일 하나로, 통과를 exit-code로#

폐기 후 남은 설계는 단순하다.

(a) 완료 상태의 단일 authority = checklist.json. 당시에는 Work마다 docs/works/<W>/checklist.json이었고, 지금은 계획 디렉토리마다 docs/plans/<날짜>-<slug>/checklist.json이 plan.md 옆에 놓인다. 이 파일 하나가 완료 상태를 소유한다. 스키마는 최소한이다.

[
  { "id": "R2", "description": "verify-done 게이트",
    "acceptance": "active checklist 미완 시 FAIL",
    "verify": "bash scripts/verify-done.sh", "passes": false }
]

항목은 계획에서 파생되고(당시에는 planning-results.md, 지금은 plan.md의 ## 완료 조건), passes는 생성 시 항상 false로 강제된다(default-FAIL 계약). 상태 표현을 하나로 축소한 것도 의도적이었다 — 당시에는 progress.md에서 상태 컬럼을 걷어내 서술 전용으로 두어, 완료 상태가 두 곳에서 어긋나는 drift를 원천 차단했다. (progress.md는 이후 Work 시스템과 함께 사라졌고, 지금 영속 상태는 plan.md와 checklist.json 둘뿐이다.)

(b) 통과는 모델이 아니라 명령이 결정한다. 핵심은 통과 연산이다. 모델이 “이거 됐어"라고 말한다고 passes:true가 되지 않는다. 체크리스트 도구의 complete <id>(이 글을 쓸 때의 이름은 pass)는 그 항목의 verify 명령을 실제로 실행하고, exit code가 0일 때만 통과로 뒤집는다. 아래는 현재 checklist.py를 줄인 것이다.

def cmd_complete(plan_dir, item_id):
    # ... 항목 조회 ...
    rc, tail = _run_verify(verify, plan_dir)  # 레포 루트에서, 600초 타임아웃
    if rc != 0:
        # 실패한 verify는 절대 flip하지 않는다 — 증거 없는 통과 거부
        return 1
    target["passes"] = True
    target["evidence"] = f"verify exit 0: {verify}"

이 한 줄 — if rc != 0: return 1 — 이 설계 전체의 급소다. 통과 여부는 모델의 “됐다"는 느낌이 아니라 verify 명령의 raw exit-code로 결정된다.

다만 정직하게 적어둘 한계가 있다. 이 계층이 막는 것은 “verify를 돌리지 않고 통과를 찍는” 것이지, “verify를 true로 적어두는” 것까지는 아니다. verify 문자열은 계획에서 파생된다는 것이 전제이고, 실행자가 스스로 지어낸 trivial verify(true/echo ok)는 이 코드가 아니라 계획 리뷰에서 걸러야 한다. 게이트는 입력이 정직할 때만 정직하다 — 이건 이 설계에 대한 fresh-context 적대적 리뷰가 정확히 짚어준 지점이다(아래 6절). 그리고 passes는 뒤집힌 시점의 verify 성공 기록일 뿐이라, 그 뒤의 회귀는 상태 조회만으로는 잡지 못한다. 지금의 도구에는 전 항목의 verify를 다시 돌리는 opt-in verify 명령이 있지만, 자동 게이트는 이를 돌리지 않는다.

(c) verify-done.sh가 이 파일을 직접 읽어 결정론으로 판정한다. 당시에는 active Work, 지금은 활성 계획(plan.md의 status가 done이 아닌 것)의 checklist.json에 passes:false가 하나라도 남아 있으면 킷 레포의 완료 게이트는 FAIL한다. 체크리스트가 없으면 스킵(회귀 없음). 종이 게이트가 아니라 기계 게이트다.

여기에 test-ratchet 하나를 더 얹었다 — diff에서 테스트/assert 수가 TEST-RATCHET-ALLOW 마커 없이 순감소하면 FAIL. “구현을 통과시키려고 테스트를 지우는” 우회를, 산문 규율이 아니라 코드로 막는다. 이것도 킷 레포의 verify-done.sh 안에 있는 검사다.

4. 정직한 범위 선언: 재발명하지 않은 것#

설계에서 가장 중요한 결정은 만들지 않기로 한 것이었다.

킷은 “fresh-context-per-iteration 루프 엔진"을 스스로 구현하지 않는다. 당시 판단으로는 플러그인이 프로그래밍으로 새 세션을 띄울 수 없었기 때문이다(/loop·ultracode는 사용자 트리거, 대화형 전용). 그래서 루프 엔진은 네이티브에 위임한다 — 세션 내 반복은 Task별 fresh subagent, 크로스프로세스 반복은 사용자가 트리거하는 네이티브 기능. 킷이 더하는 것은 그 패턴의 상태·게이트 레이어뿐이다. 네이티브에 이미 있는 것을 재구현하는 것은 기술 부채이지 기능이 아니다.

마찬가지로, 새 rule 파일을 만들고 싶은 유혹도 눌렀다. 규율은 새 문서가 아니라 이미 있는 문서에 한두 줄로 얹었다 — loop-engineering 규칙에 “요약이 아니라 계획 원본을 재독하라”(장기 루프에서 대화 요약은 열화한다)와 “진전이 없으면 멈춰라”(모델의 ‘작업 중’ 주장이 아니라 관찰 가능한 신호로 판정). 지금 그 규칙의 표현은 “계획 plan.md 원본 재확인"과 “같은 항목이 두 번 연속 새 커밋·checklist 통과 없이 끝나면 에스컬레이션·중단 보고"다. single-source-of-truth를 지키는 것이 규율을 늘리는 것보다 중요하다.

5. 다시, 작성자는 오염돼 있다#

구현을 마친 뒤 — verify-done.sh가 초록불을 켠 뒤 — 나는 그 코드를 fresh context의 적대적 리뷰어에게 다시 넘겼다. 이건 지난 글의 교훈을 그대로 실천한 것이다. 완료 게이트를 통과했다는 것과 결함이 없다는 것은 다르다. 자기 검증은 자기가 닫았다고 믿은 구멍을 놓친다.

그리고 이 원칙 자체를 킷에도 새겼다 — review-code 에이전트 문서에 “작성자≠검증자(fresh context, 읽기 전용)는 충족“이라고 명시하되, “같은 모델 계열 리뷰어의 self-preference bias(자기 계열 산출물을 관대하게 보는 편향)는 아직 미충족 갭“이라고 정직하게 남겼다. 충족한 것과 못 한 것을 구분해 적는 것 — 그것이 다음 사람이 신뢰할 수 있는 유일한 기록이다.

6. 그 리뷰가 실제로 잡은 것#

원칙은 그럴듯하지만, 검증은 결과로 말한다. fresh-context 리뷰어는 초록불이 켜진 코드에서 두 개의 진짜 블로커를 찾아냈다.

하나 — 완료 경로가 애초에 동작하지 않았다. 통과 연산이 verify를 실행할 때의 작업 디렉토리를 나는 work_dir의 고정된 상위 깊이(parents[2])로 계산했다. 그런데 당시 실제 Work 경로는 docs/works/active/<W>/였고, 그 깊이에서 parents[2]는 repo 루트가 아니라 docs/였다. 결과: pytest tests/...나 ./scripts/x.sh 같은 정상적인 verify가 전부 “파일 없음"으로 exit 1 → 통과가 영구히 거부 → 완료 게이트가 영원히 FAIL. 완료를 판정하려고 만든 코드가 완료를 원천 봉쇄하고 있었다. fail-safe이긴 했지만(잘못된 PASS는 없다) 기능 자체가 죽어 있었다. git rev-parse --show-toplevel로 실제 루트를 구하도록 고쳤다 — 계획 디렉토리가 어느 깊이로 넘어오든 맞는 방식이라, 경로 규약이 바뀐 지금도 그대로다.

둘 — 막겠다고 한 우회를 정작 놓쳤다. Bash로 쓴 .py를 검증에 포함시키는 안전망(stop-validator 훅)을 만들면서, 나는 셸 리다이렉트(>, tee, sed -i)만 감지했다. 리뷰어는 정확히 이 방어가 겨냥한 코드젠 벡터 — python -c "open('gen.py','w').write(...)" — 가 셸 write 연산자를 안 쓰므로 그대로 통과한다고 지적했다. 인터프리터 인라인 쓰기(open(...'w'), write_text, .write()도 감지하도록 확장했다.

여기에 더해 이 글의 3절이 “피검자가 게이트 입력을 세팅할 수 없다"고 단언했던 것도 리뷰어가 붙잡았다 — verify 문자열은 실행자가 체크리스트를 init할 때 직접 적으므로, 그 주장은 verify가 정직할 때만 성립한다. 그래서 이 글도, 코드의 docstring도 그 한계를 명시하도록 고쳤다(3절의 현재 문장이 그 결과다).

교훈은 지난 글과 같지만 더 날카롭다. 내 코드는 초록불이었다. 기계 게이트를 전부 통과했다. 그런데도 완료 경로는 죽어 있었고 방어는 뚫려 있었다. 자기 검증으로는 절대 못 봤을 것들이다 — 작성자는, 언제나, 오염돼 있다.


부산물 하나. 이 작업을 하던 긴 세션에서 도구 호출이 자꾸 깨지며 작업이 중간에 멈추는 일이 반복됐다. 원인을 플러그인·터널·모델로 옮겨다니며 헤맸지만, 소거법 끝에 남은 답은 롱컨텍스트 열화였다 — 세션이 수 시간에 걸쳐 극도로 길어지면 특수 토큰(함수 호출) 생성이 먼저 불안정해진다. 우리가 이번에 리서치한 “context rot"이 정확히 그 현상이었다. 컨텍스트를 압축(/compact)하자 즉시 복구됐다. 이론이 예측한 실패를 우리 자신이 겪은, 조금 부끄럽지만 정직한 각주다.

이후 바뀐 것#

2026-10-02 업데이트. 본문은 2026-07 시점의 기록으로 두고, 지금과 달라진 부분은 본문에 “당시/지금"으로 표기했다.

  • Work 시스템 제거 (v4.0.0). docs/works/<W>/·planning-results.md·progress.md가 사라지고 계획 파일 한 장(docs/plans/<날짜>-<slug>/plan.md)으로 바뀌었다. checklist.json은 그 옆에 그대로 놓이고, 항목의 verify는 plan.md의 ## 완료 조건에서 파생된다.
  • checklist pass → checklist complete (v5.0.1). 이름만 바뀌었고 동작(verify exit 0일 때만 통과)은 같다. 옛 이름 pass는 6.0.0까지 별칭으로 받는다.
  • 체크리스트 도구 위치 이동 (v5.2.0). checklist.py가 plugins/common/hooks/에서 plugins/common/tools/로 옮겨졌다. scripts/checklist.sh는 킷 레포 전용 래퍼이고, 플러그인 설치본에서는 플러그인 안의 tools/checklist.py를 직접 부른다.
  • verify-done.sh는 킷 레포 전용 게이트로 명시. 체크리스트 완료 검사와 test-ratchet은 킷 레포의 게이트에 있고 플러그인에 실리지 않는다. 검사 대상도 “active Work"에서 “활성 계획(status가 done이 아닌 plan.md)“으로 바뀌었다.
  • loop-engineering 규칙 문구 갱신. 재앵커 대상이 plan.md 원본으로, “커밋 0건이면 idle 종료"가 “같은 항목이 두 번 연속 진전 없이 끝나면 에스컬레이션·중단 보고"라는 무진전 가드로 바뀌었다. 이 규칙은 이제 활성 계획이 있을 때만 주입된다.
  • 킷 축소 (v5.0.0). 에이전트는 절반 넘게, 스킬과 규칙도 일부 줄었지만, 이 글에 나온 review-code 에이전트, loop-engineering 규칙, stop-validator 훅은 남아 있다. 체크리스트를 만들고 항목을 하나씩 통과시키는 실행 주체는 지금 auto-dev 스킬이다.
  • 용어 정리. “Initializer-Executor"는 킷의 기능 이름이 아니라 우리 리서치가 Anthropic의 장기 실행 하네스 패턴에 붙인 이름이라, 본문 표현을 그에 맞게 고쳤다.