사람이 없어도 완료를 지어내지 않기: sparring v0.5.0
sparring v0.5.0에는 사람이 자리를 비운 동안에도 디자인 결정을 잃지 않고 정직하게 멈추는 unattended mode가 추가됐다. 계획과 실행을 /spar:ready와 /spar:fight로 분리한 이유, 크로스모델 리뷰가 지속형 큐에서 찾아낸 결함, 그리고 도구가 자기 회로차단기에 걸렸다가 재시도로 수렴한 과정도 함께 정리한다.
· 6 min read
사람이 없을 때는 어떻게 멈춰야 할까 #

지난 글에서는 여러 단계의 계획을 독립 리뷰 루프에 태우는 /spar-weighin을 소개했습니다. 계획을 Task로 나누고, 각 Task가 수렴할 때만 다음으로 넘어가는 오케스트레이터였습니다.
그런데 이 흐름에는 사람이 꼭 필요한 지점이 하나 남아 있었습니다. reviewer들이 설계 문제에 합의하지 못하면 루프를 멈추고 사용자의 결정을 기다리는 디자인 게이트입니다. 사람이 지켜볼 때는 올바른 동작이지만, 예약 실행이나 장시간 작업처럼 자리를 비운 상황에서는 세션이 끝나지 않은 채 멈춰 있게 됩니다.
v0.5.0은 이 문제를 해결하면서 명령어 구조도 함께 손봤습니다.
- unattended mode: 사람이 없어도 디자인 결정을 잃지 않고, 완료를 지어내지 않은 채 세션을 끝냅니다.
/spar:ready와/spar:fight: 한 명령어에 섞여 있던 계획과 실행을 분리했습니다.
두 변화는 같은 방향을 가리킵니다. 자동화를 더 오래 돌리는 것보다 중요한 것은, 자동화가 어디까지 해냈고 왜 멈췄는지를 정직하게 남기는 것입니다.
unattended mode: 결정은 보류하되 세션은 끝낸다 #
reviewer가 제기한 [DESIGN] finding이 두 라운드 연속 합의에 이르지 못하면 해당 finding은 parked 상태가 됩니다. 기존에는 이 순간 사람의 입력을 기다렸습니다.
--unattended 모드에서는 기다리는 대신 다음 순서로 종료합니다.
해결되지 않은 디자인 finding
↓
reviews/spar-pending.md에 저장
↓
blocked-pending-user로 결과 기록
↓
리포트 생성 시도 → 상태 정리 → 세션 종료기계적으로 고칠 수 있는 문제는 이전처럼 계속 수정합니다. 끝까지 남은 디자인 교착만 사람의 판단이 필요한 본질적 결정으로 간주합니다.
대기 항목은 정리 단계가 지우지 않는 reviews/spar-pending.md에 보관됩니다. 여러 실행에서 같은 문제가 다시 발견되더라도 review ID와 finding 지문을 기준으로 병합하고 중복을 제거합니다. 다음 세션이 시작되면 SessionStart 훅이 대기 중인 디자인 결정의 개수를 알려줍니다.
여기서 가장 중요한 점은 종료 상태입니다. 사람이 없어 결정을 내리지 못한 작업은 절대 converged로 기록되지 않습니다. 상태는 blocked-pending-user로 남고, 결정은 다음 세션으로 넘어갑니다.
즉 unattended mode의 목적은 사람을 흉내 내는 것이 아니라, 사람이 필요한 지점을 잃지 않은 채 안전하게 멈추는 것입니다.
이 모드는 자동으로 감지하지 않고 명시적인 --unattended 플래그로만 활성화됩니다. 파이프나 CI에서 실행됐다는 이유만으로 사용자가 자리를 비웠다고 추측하면 안 되기 때문입니다.
계획과 실행을 분리하다: <code>/spar:ready</code>와 <code>/spar:fight</code> #
/spar-weighin은 복싱의 계체량에서 따온 이름이었습니다. 하지만 실제 동작은 계체량에서 끝나지 않았습니다. 계획을 만든 뒤 곧바로 모든 Task를 실행했습니다. 준비를 뜻하는 이름과 끝까지 실행하는 동작이 맞지 않았습니다.
사용하는 입장에서도 불편했습니다. 계획을 먼저 읽고 수정하고 싶어도 명령어가 실행까지 이어갔기 때문입니다. 그래서 준비와 실행을 두 명령어로 나눴습니다.
| 명령어 | 역할 |
|---|---|
/spar:ready <스펙> | 스펙을 체크박스 계획으로 만들고 전용 브랜치에 올린 뒤 멈춥니다. |
/spar:fight | 준비된 계획의 Task를 하나씩 실행합니다. 계획이 없으면 인자로 받은 단일 작업을 실행합니다. |
/spar:cancel | 진행 중인 작업을 취소합니다. |
이제 흐름은 단순합니다.
/spar:ready <스펙>
↓
계획 검토·수정
↓
/spar:fightready에서 멈추기 때문에 계획을 검토하고 고칠 자연스러운 체크포인트가 생겼습니다. 계획에 만족하면 그때 fight를 실행하면 됩니다.
한 가지 안전 규칙도 추가했습니다. 준비된 계획이 있는 상태에서 /spar:fight에 별도의 단일 작업을 함께 넘기면 실행을 거부합니다. 기존 계획을 잊은 채 다른 작업을 시작하는 실수를 막기 위해서입니다.
커맨드뿐 아니라 내부 용어도 ready·fight·plan으로 통일했습니다. 복싱의 은유는 유지하되, 매일 입력하는 명령어는 동작을 바로 이해할 수 있는 이름으로 바꾼 것입니다.
크로스모델 리뷰가 지속형 큐를 단단하게 만든 과정 #
unattended mode의 핵심은 미결정을 다음 세션까지 잃지 않는 것입니다. 따라서 지속형 큐는 단순한 부가 기능이 아니라 신뢰성의 중심이었습니다.
독립 reviewer는 라운드마다 서로 다른 결함을 하나씩 찾아냈습니다.
1. 본문 없는 항목이 중복으로 오인될 수 있었다 #
처음에는 헤딩과 본문을 차례로 추가했습니다. 헤딩을 쓴 뒤 본문 쓰기에 실패하면 헤딩만 남습니다. 다음 재시도는 그 헤딩을 보고 이미 저장된 항목으로 판단하므로, 본문이 영구히 사라질 수 있었습니다.
해결 방법은 큐 전체를 임시 파일에 먼저 완성한 뒤 mv로 교체하는 것이었습니다. 쓰기 도중 실패하더라도 기존 파일은 온전히 남습니다.
2. 심링크 검사의 시점이 잘못돼 있었다 #
큐 경로가 심링크인지 확인하고 있었지만, 검사가 락을 기다리기 전에 실행됐습니다. 락을 기다리는 동안 큐가 심링크로 바뀌면 검사를 우회할 수 있었습니다.
특히 mv의 대상이 외부 디렉터리를 가리키는 심링크라면, 파일을 그 외부 디렉터리 안으로 옮기고도 성공을 반환한다는 사실을 재현했습니다. 검사를 락 획득 뒤로 옮기고, 교체 직전에도 안전한 대상인지 다시 확인하도록 보강했습니다.
3. 경로가 명령어 옵션으로 해석될 수 있었다 #
-로 시작하는 경로를 cat이나 mv에 그대로 넘기면 파일명이 아니라 옵션으로 해석될 수 있었습니다. 경로를 인자와 명확히 구분하도록 수정했습니다.
이 결함들은 이미 배포된 코드에서 발견된 것이 아닙니다. v0.5.0을 내놓기 전, 독립된 크로스모델 리뷰가 실제 실행 경로를 따라가며 찾아낸 문제들입니다.
도구가 자기 회로차단기에 걸리다 #
지속형 큐의 첫 구현은 리뷰 라운드 상한인 5라운드에 도달해 미수렴으로 끝났습니다.
고칠 수 없는 결함이 남아서가 아니었습니다. reviewer가 매 라운드 새로운 결함을 하나씩 찾아냈고, 다섯 번째 수정을 검증할 라운드가 더는 남아 있지 않았기 때문입니다.
여기서 sparring은 완료를 선언하지 않았습니다. 라운드 상한은 일정 횟수 뒤 작업을 통과시키는 장치가 아니라, 끝없는 수정 루프를 끊는 회로차단기입니다. 상한에 도달하면 이유와 관계없이 미수렴으로 멈춥니다.
저는 종료 사유를 확인한 뒤, 다섯 라운드의 수정이 반영된 코드를 새 baseline으로 삼아 다시 실행했습니다. 이미 주요 결함이 제거된 상태였기 때문에 두 번째 실행은 1~2라운드 안에 수렴했습니다.
이 경험은 재시도의 기준도 보여줬습니다.
- 매 라운드 서로 다른 실제 결함이 발견됐고 모두 수정됐다면, 라운드 부족으로 판단해 재시도할 수 있습니다.
- 같은 결함이 반복되거나 현재 접근으로 해결할 수 없는 문제라면, 재시도 대신 설계나 방향을 바꿔야 합니다.
미수렴은 실패를 감추는 상태가 아니라, 다음 행동을 결정하기 위해 원인을 읽으라는 신호입니다.
리네임 리팩터에 sparring을 쓰지 않은 이유 #
/spar-weighin을 /spar:ready와 /spar:fight로 나누려면 stop-weighin.sh, spar-weighin-launch.sh 같은 훅 스크립트의 이름과 역할도 바꿔야 했습니다.
문제는 sparring 자체가 바로 그 훅으로 동작한다는 점입니다. 루프는 에이전트가 멈출 때마다 훅을 실행해 현재 Task의 상태를 확인하고 다음 Task로 전진합니다. 실행 중인 루프로 훅의 이름을 바꾸면, 다음 정지 시점에 이미 사라진 훅을 호출할 수 있습니다.
달리는 차가 자기 엔진을 교체하는 것과 비슷합니다.
Phase 5는 기존 경로를 유지한 채 기능을 추가하는 작업이라 sparring으로 검증할 수 있었습니다. 반면 이번 리팩터는 기존 경로를 제거하고 이름을 바꾸는 작업이었습니다. 자기수정의 위험이 더 컸습니다.
그래서 이 작업에는 평범한 TDD를 사용했습니다.
- 변경될 동작에 맞춰 테스트를 먼저 수정하거나 추가했습니다.
- 스크립트와 명령어를 단계적으로 리팩터링했습니다.
- 각 단계에서 전체 테스트 스위트를 실행했습니다.
- 마지막에는 임시 저장소에서
ready → 계획 확인 → fight → Task 전진흐름을 직접 재현했습니다.
도구의 방법론을 도구 자신에게 적용하는 것이 항상 가장 안전한 선택은 아닙니다. 검증 대상이 검증 도구의 실행 기반 자체라면, 그 기반과 독립된 테스트가 더 나은 안전망이 됩니다.
테스트와 현재 상태 #
Phase 5와 명령어 재정비는 v0.5.0에 반영됐습니다.
- 순수 bash 테스트 스위트 16개 파일에서 총 387개 assertion 통과
- 핵심 라운드 엔진인 stop-hook 스위트에서 195개 assertion 통과
- unattended 종료 경로를 추가한 뒤에도 기존 attended 동작이 그대로 유지됨을 확인
- 리네임 뒤
weighin,wgn,worktree:토큰이 플러그인과 테스트에 남지 않았음을 grep으로 확인
최종 리포트 생성기인 spar-report.sh는 별도 설계가 필요해 이번 범위에서 제외했습니다. 대신 unattended 종료 경로에 fail-open 방식의 호출 지점을 마련했습니다. 생성기가 아직 없거나 실행에 실패하더라도 본래의 종료와 상태 정리를 방해하지 않습니다.
새 명령어를 사용하려면 플러그인을 v0.5.0으로 다시 설치해야 합니다. 설치된 플러그인 캐시는 저장소와 별개의 사본이기 때문입니다.
다음 단계 #
로드맵 기준으로 남은 단계는 다음과 같습니다.
- Phase 5 잔여 작업: 완료된 실행을 정리하는 최종 보고서(
/spar-report) 생성기 - Phase 6: Codex가 작성하고 Claude가 검토하는 mirror adapter
- Phase 7: diff 크기에 따른 reviewer reasoning effort, 저비용 fix writer 등 model economics
sparring의 원칙은 그대로입니다.
- 규칙은 모델의 기억이 아니라 상태 머신과 파일 구조가 지킵니다.
- 완료 여부는 작성자가 아니라 독립된 reviewer가 판단합니다.
- 수렴하지 못했다면 그 이유를 숨기지 않고 그대로 남깁니다.
v0.5.0은 이 원칙을 사람이 없는 상황까지 확장했습니다. 무인 실행에서도 완료를 지어내지 않고, 사람이 결정해야 할 문제는 잃어버리지 않은 채 다음 세션으로 넘깁니다.
동시에 명령어도 실제 동작에 맞게 정리했습니다. 먼저 ready로 계획하고, 확인한 뒤 fight로 실행합니다.
더 오래 자동으로 돌리는 것보다 중요한 것은, 자동화가 스스로 결정할 수 없는 순간에 정직하게 멈추는 것입니다.