
설계·구현·테스트를 하나씩 맡겨 서브에이전트 셋을 순서대로 띄웠다. 첫 번째가 done 을 돌려주자 두 번째가 그 결론을 바탕으로 구현했고 세 번째가 테스트했다. 테스트에서 문제가 나와 원인을 찾다 보니 첫 번째로 돌아가야 했다. 두 번째와 세 번째 작업도 함께 되돌렸다.
에이전트 셋은 대화를 공유하지 않아 앞 단계의 인수인계서에 의존한다. done으로 보고할 수 있는 조건을 정하지 않으면 다음 에이전트가 검증되지 않은 결과로 작업을 시작할 수 있었다.
curvez 는 그 인수인계서를 핸드오프라고 부른다. 형식을 정해 둔 것이 agent-contract 스킬이다. done 에 검증을 붙였다는 얘기는 예전에 한 번 적었다. 그때는 규칙만 설명했으니 이번에는 계약에 실제로 무엇을 담는지 보려고 한다.
이 저장소의 .curvez/handoff/ 에 실제로 오간 파일 세 건이 남아 있다. 먼저 그걸 열어 봤다.
| 파일 | 크기 | verification | status |
|---|---|---|---|
curvez-architect.20260907-150907.json | 2,642B | 6항목 | done |
curvez-designer.20260907-162648.json | 5,386B | 8항목 | partial |
curvez-nextjs.20260907-164829.json | 6,383B | 8항목 | partial |
표 1. 실제로 오간 핸드오프 세 건
셋 다 최상위 키가 같은 여덟 개다 — from·to·status·summary·artifacts·decisions·blocked_on·verification. 형식이 정해져 있으니 python3 -c "json.load(...)" 한 줄로 열어 필드를 그대로 꺼낼 수 있다.
verification 안에는 이런 것들이 들어 있다. 셋 다 명령 문자열과 그 출력이 한 쌍으로 적혀 있다.
grep -cE '^\| ARCH-[0-9]{3} \|' .curvez/architecture.md → 6
pnpm lint → exit 0, 0 errors 0 warnings
find .curvez/design/screens -name '*.md' | wc -l → screens=3
세 건 중 done 은 하나뿐이고 둘은 partial 이다. 형식이 done 을 기본값으로 두지 않아서 그렇다.
1. 다음 작업에 필요한 인수인계 정보
핸드오프에는 사람이 읽을 요약뿐 아니라 프로그램이 처리할 정보도 필요하다. 스킬 문서는 이를 다음과 같이 정의한다.
핸드오프는 curvez 팀 실행의 유일한 통신 수단이다. 에이전트끼리 실시간으로 대화하지 못하므로, 파일에 남긴 계약이 곧 API 다.
사용자에게 하는 최종 보고는 이 계약으로 쓰지 않는다. 핸드오프는 프로그램이 읽는 계약이고 사용자 보고는 사람이 읽는 문장이다. 둘을 섞으면 계약에 서사가 끼어들어 프로그램이 값을 꺼내다 실패한다. 반대로 보고에는 형식이 끼어들어 사람이 못 읽게 된다.
2. 핸드오프 JSON의 필수 항목
파일을 어디에 두는지부터 정해져 있다. 경로 형식이 이렇다.
.curvez/handoff/<from>.<timestamp>.json
<from> 은 만든 에이전트의 name 그대로다. <timestamp> 는 YYYYMMDD-HHmmss 다. curvez-architect.20260816-141203.json 같은 이름이 된다. 같은 에이전트가 다시 돌아도 앞 파일을 덮어쓰지 않는다. 회고 에이전트가 이 디렉터리를 시간순으로 읽어 실행 이력을 재구성해서다. 덮어쓰면 무엇이 언제 어긋났는지 복원할 수 없다.
내용은 JSON 으로 적는다. 이렇게 생겼다.
{
"from": "curvez-architect",
"to": ["curvez-nextjs"],
"status": "done",
"summary": "DDD 4레이어로 확정. 도메인은 프레임워크 import 금지.",
"artifacts": [
{
"path": ".curvez/architecture.md",
"kind": "decision",
"note": "레이어 경계 규칙 포함"
}
],
"decisions": [
{
"what": "application 레이어를 별도로 두지 않고 domain 에 유스케이스를 합쳤다",
"why": "화면이 12개 이하라 레이어 하나를 더 두면 파일만 늘고 경계는 안 생긴다",
"reversible_at": ".curvez/architecture.md:레이어 정의"
}
],
"blocked_on": [],
"verification": [
{
"command": "node scripts/validate-handoff.mjs",
"result": "오류 0개",
"passed": true
}
]
}
인수인계서로 치면 각 칸이 이런 것들이다.
| 필드 | 담는 것 |
|---|---|
from · to | 누가 써서 누구에게 넘기는가 |
status | 어디까지 끝났는가. done · partial · blocked 셋 중 하나 |
summary | 무엇을 했는지 한 줄 |
artifacts | 실제로 만들거나 고친 파일 목록 |
decisions | 무엇을 왜 그렇게 정했는가 |
blocked_on | 답을 못 얻어 멈춘 질문. 없으면 빈 배열 |
verification | 실제로 돌린 명령과 그 출력 |
표 1. 핸드오프 JSON 의 일곱 필드
to 는 최소 한 개다. 다음 담당이 안 정해졌으면 ["curvez-orchestrator"] 로 돌려준다. 빈 배열은 허용하지 않는다. 받는 쪽이 없으면 아무도 안 읽고 다음 라운드에서 같은 작업이 다시 돈다.
artifacts 의 kind 는 열 개로 정해져 있다 — decision code doc test spec research review retro commit pr. 커밋은 path 에 git:<40자 해시> 를 넣는다. PR 은 URL 전문을 넣는다. 다음 회고에서 이전 회고를 찾을 때 프로젝트의 모든 문서를 훑지 않도록 retro 와 doc 도 구분했다.
decisions 안에서 실제로 쓸모가 있는 필드는 reversible_at 이다. 파일:섹션 또는 파일:라인 으로 적는다. 이게 없으면 나중에 그 결정을 뒤집을 때 전체를 다시 읽어야 한다. 대신 아무거나 다 남기지는 않는다. 파일명을 kebab-case 로 했다 같은 관례적 기본값은 안 남긴다. 전부 남기면 아무도 안 읽는다.
3. done·partial·blocked의 구분
status 에 쓸 수 있는 값은 아래 셋뿐이다.
| status | 조건 | 강제 규칙 |
|---|---|---|
done | 맡은 범위를 끝냈고 검증까지 마쳤다 | verification 최소 1건. blocked_on 이 비어야 한다 |
partial | 일부만 끝냈다. 남은 것을 알고 있다 | 무엇까지 됐고 무엇이 남았는지 summary 에 적는다 |
blocked | 답 없이는 더 못 간다 | blocked_on 최소 1건 |
표 2. status 세 값의 조건과 강제 규칙
done 아니면 실패, 둘이면 충분해 보이지만 blocked 도 정상 상태로 다룬다. blocked 를 받은 오케스트레이터는 사용자에게 묻거나 다른 에이전트에게 돌린다. 추측으로 메운 done 은 아무도 못 잡아내고 나중에 되돌리는 비용이 훨씬 크다.
blocked_on 에 적는 질문에도 형식이 있다. { "question": "주문 취소 가능 기간이 며칠인가", "who": "user" } 처럼 답을 줄 주체를 같이 적는다. who 는 user 이거나 에이전트 name 이다. 질문은 답하면 바로 진행 가능한 형태로 적어야 한다. "요구사항이 불명확하다" 고만 적어서는 답할 수 없다.
판정이 애매한 자리는 스킬에 예시로 박혀 있다.
| 상황 | status |
|---|---|
구현 끝, pnpm typecheck 0 errors | done |
| 구현 끝, 테스트는 못 돌림 | partial |
| 화면 3개 중 2개 구현 | partial |
| API 응답 형식을 모름 | blocked |
| 앞 단계 결정에 이의 있음 | blocked |
표 3. 판정이 애매한 자리의 status 예시
4. 검증 명령과 결과 기록하기
verification 은 실제로 돌린 명령과 그 결과를 옮겨 적는 자리다. 받는 쪽은 "통과" 두 글자로 아무것도 판정하지 못한다. 그래서 요약하지 말고 판정 가능한 값으로 적으라고 되어 있다.
| 좋음 | 나쁨 |
|---|---|
{ "command": "pnpm typecheck", "result": "0 errors" } | { "command": "타입 확인", "result": "통과" } |
{ "command": "pnpm test -- order", "result": "12 passed, 0 failed" } | { "command": "pnpm test", "result": "정상 동작" } |
표 4. verification 에 적는 좋은 예와 나쁜 예
오른쪽 첫 줄은 명령을 재현할 수 없고 "통과" 만으로는 판정 근거를 알 수 없다. 두 번째 줄은 더 나쁘다. 몇 개가 돌았는지가 없으니 0개 실행도 "정상 동작"이 되어 버린다.
실패한 검증도 그대로 적는다. { "command": "pnpm test", "result": "10 passed, 2 failed (order.spec.ts)", "passed": false } 를 적는다. 그리고 status 를 partial 이나 blocked 로 내린다. 실패를 숨기고 done 하는 것이 계약 위반이라고 못박혀 있다.
이 규칙은 검증 스크립트로도 확인한다. 핸드오프 파일을 읽고 규칙을 어긴 항목을 오류로 출력한다.
node "$CLAUDE_PLUGIN_ROOT/scripts/validate-handoff.mjs" .curvez/handoff/
무엇을 막는지 보면 이렇다.
done인데verification이 비었다 → 오류. 아무것도 안 돌려 보고 끝났다고 한 것이다blocked인데blocked_on이 비었다 → 오류. 무엇을 물어야 하는지가 없다done인데blocked_on이 남아 있다 → 오류. 끝났는데 답을 기다린다는 말이 된다done인데artifacts와decisions가 둘 다 비었다 → 경고. 남긴 게 없는 완료인지 확인하라고 한다
정해진 목록에 없는 최상위 키도 오류다. 왜 막는지는 스크립트 주석에 이유가 있다. 오타 난 키는 조용히 무시된다. 쓴 쪽은 데이터를 남겼다고 믿는다. 읽는 쪽은 그것을 못 본다.
5. 핸드오프를 받은 에이전트의 확인 순서
작성 형식과 함께 받은 파일을 확인하는 순서도 정했다.
status를 먼저 본다.blocked나partial이면 그 전제 위에서 작업을 시작하지 않는다blocked_on을 본다.who가 내name인 질문이 있으면 그것부터 답한다decisions를 본다. 내 작업과 충돌하는 결정이 있어도 뒤집지 않고blocked_on에 이의를 남긴다artifacts의 파일을 실제로 읽는다
3번을 지켜야 하는 건 앞 단계의 결정을 뒤에서 조용히 뒤집으면 두 산출물의 전제가 서로 달라지고 어느 쪽이 맞는지 판정할 근거가 사라지기 때문이다. 그래서 이의는 남기되 뒤집지는 않는다.
제일 어기기 쉬운 건 4번이다. 한 줄짜리 summary 를 읽는 것과 달리 artifacts 는 파일 여러 개를 열어야 한다. 스킬에도 "summary 만 읽고 진행하지 마라" 라고 한 줄 따로 박혀 있다.
6. 인수인계 형식을 유지하는 비용
| 무엇을 내주는가 | 어떻게 나타나는가 |
|---|---|
| 에이전트 한 번 실행이 길어진다 | 자기 검증 명령을 실제로 돌리고 출력에서 판정 가능한 부분을 옮겨 적어야 한다 |
| 파일이 계속 쌓인다 | 덮어쓰지 않으니 라운드마다 .curvez/handoff/ 가 늘어난다. 오래된 것을 언제 지우는지는 스킬에 적혀 있지 않다 |
| 검증기는 형태만 본다 | result 에 "0 errors" 라고 적혀 있으면 통과시킨다. 그 명령을 실제로 돌렸는지는 판정하지 않는다 |
표 5. 이 계약을 쓰면서 내주는 것
이 계약으로 확인할 수 있는 범위는 마지막 줄까지다. 검증기는 "검증을 적었는가" 까지만 보고 "적힌 게 사실인가" 는 못 본다. 기록된 결과가 사실인지 별도로 검증하는 방법은 확인하지 못했다.
7. done으로 보고할 수 있는 조건
done 의 뜻이 "맡은 일을 마쳤다" 에서 "돌려서 나온 값을 여기 적어 뒀다" 로 좁혀졌다. 그 뜻을 안 지킨 파일은 검증기를 통과하지 못하게 해 뒀다.