독자는 둘, 마지막 답은 하나
Coop 워커가 쓰는 마지막 답엔 독자가 둘이야. 둘이 원하는 게 정반대고. 아빠는 채팅을 읽어. Desktop 앱에서, 터미널에서, 폰에서. 무슨 일이 있었는지, 어디에 반영됐는지, 자기가 할 일이 있는지를 몇 줄로, 자기 말로 알고 싶어 해. 엔진은 똑같은 텍스트를 읽고 커밋할 기록을 원해. 어느 바인딩인지, 결과가 뭔지, 증거가 뭔지. 문자열 하나에 청중 둘. 한동안은 엔진이 이기고 아빠가 졌어.
시작은 엔진이 너무 깐깐했던 거야. 2026-09-30까지 report_text는 마지막 답 전체가 JSON이거나, JSON 펜스 블록 하나이길 요구했어. 워커가 JSON 앞에 아빠한테 한 문장만 써도, 하네스가 뒤에 자기 꼬리를 붙여도(Codex Desktop의 메모리 인용 같은 거), 보고는 아무 말 없이 사라졌어. 커밋 a17c3a3이 파서를 너그럽게 바꿨어. 모든 {에서 json.JSONDecoder.raw_decode로 훑고, coop_reports 리스트를 가진 첫 객체가 보고야. 어디 있든. 옳은 수였어. 그런데 그 말은 워커가 JSON을 아무 데나 붙여도 된다는 뜻이기도 했고, 실제로 그렇게 했어. 아빠 채팅 끝엔 중괄호랑 따옴표 친 키가 줄줄이 달렸지.
정의는 하되 그리지는 않는 문법
해법은 마크다운에서 다들 잘 안 들여다보는 구석에서 나왔어. 링크 참조 정의야. 참조식 링크는 본 적 있을 거야. 본문엔 [docs][1], 맨 아래엔 [1]: https://example.com. 그 아래 줄이 라벨을 정의해. CommonMark 명세는 이 줄이 문서의 구조 요소에 해당하지 않는다고 대놓고 말해. 정의 한 줄만 든 문서는 눈에 보이는 내용이 하나도 없다는 예제까지 있고.
그러니까 정의는 렌더러가 읽기는 하고 그리지는 않는 줄이야. 커밋 ba0fb04(todo #360, 2026-10-09)가 여기에 마무리를 얹었어. 이제 워커의 마지막 답은 두 부분이야. 먼저 아빠한테 하는 한국어 두세 줄, 많아야 네 줄(반말로, 아빠라고 불러서). 뭘 했는지, 어디에 반영됐는지, 아빠가 할 일이 뭔지. 그다음 빈 줄 하나. 그다음 보고 한 줄, 이런 모양으로.
[coop-report]: <{"coop_reports":[{"binding_id":…,"outcome":…,"body":…,"for_dad":…,"evidence":{}}]}>
꺾쇠괄호가 JSON 전체를 정의의 링크 목적지로 만들어. 커밋 기록에 따르면 Claude Code 터미널, react-markdown, pulldown-cmark 셋 다 이 줄에선 아무것도 안 그려. 아빠는 한국어 줄만 봐. 엔진은 여전히 JSON을 찾아. 엔진의 스캐너는 애초에 마크다운 따윈 신경 안 썼거든.
빈 줄은 장식이 아냐. 링크 참조 정의는 문단을 끊고 들어갈 수 없어. 한국어 줄 바로 밑에 정의를 붙이면 CommonMark는 그걸 같은 문단의 한 줄로 읽고, 통째로 그려.
꺾쇠 하나에 터져
꺾쇠 안의 목적지엔 줄바꿈도, 이스케이프 안 된 <나 >도 들어갈 수 없어. 이 규칙에 걸려 넘어지는 데 하루도 안 걸렸어. 어떤 워커의 보고 본문에 <repo> 자리표시자가 들어 있었어. 엔지니어가 제일 흔하게 치는 거지. 그 줄은 더 이상 정의가 아니었고, 렌더러마다 텍스트로 그렸고, 아빠는 for_dad 키까지 다 드러난 JSON을 받았어. 정작 아빠한테 하는 답은 없이. 같은 시기, 첫 작업 봉투에서 몇 턴이 지나 답글 리뷰나 후속 질문에 답하던 워커들은 규칙을 아예 놓쳐버렸어. JSON 펜스 블록을 붙이거나, 영어 문단으로 시작했지.
커밋 d2bea99(todo #375, 2026-10-10)가 둘 다 잡았어. final_form이 유일한 마무리 문구가 됐고, 봉투 종류마다 다 실려. 작업이든, 답글 리뷰든, 후속 질문이든. 첫 봉투에서 한 번 말한 규칙은 세 턴 뒤 워커한테 이미 없는 규칙이니까. 이제 이 문구는 본문과 증거까지 포함해 JSON 어디에도 <나 >를 넣지 말라고 해. 대신 뭘 쓸지도 알려줘. 화살표는 to, 자리표시자는 {repo}, 비교는 at least. 확인 토큰도 자기만의 숨은 줄로 옮겼어. [coop-read]: <COOP_READ id>, [coop-reply]: <COOP_REPLY id>. 그래서 눈에 보이는 마지막 답은 한국어 줄뿐이야.
엔진이 생각하는 숨김
엔진은 숨은 줄이 뭔지 자기 나름의 정의를 들고 있어. REPORT_LINE이야. ba0fb04의 첫 버전은 꺾쇠 사이에 아무 글자나 받아줬어. 그래서 제일 중요한 순간에 거짓말을 했지. 렌더러마다 버젓이 그리고 있는 줄을 숨었다고 판정했으니까. d2bea99가 목적지를 <[^<>\n]*>로 좁혔어. CommonMark의 목적지 문법 그대로야. 백슬래시 이스케이프만 빠졌는데, 보고 끝맺음엔 그게 필요 없어. 앞에 붙은 {0,3}조차 들여쓰기를 공백 세 칸까지 봐주는 명세 조항을 베낀 거고.
커밋은 이제 이 패턴이 CommonMark가 읽는 그대로의 정의와 맞는다고 적었어. 그 줄 하나만 보면 맞아. 주변까지 보면 아니고, 그 차이가 중요해. 이 정규식은 re.M으로 돌면서 한 번에 한 줄만 판정해. 그래서 위에 뭐가 있는지 못 봐. 완벽한 정의 줄이라도 한국어 줄 바로 밑에 붙어 있으면 그 문단의 일부로 그려지고, 코드 펜스 안에 있으면 코드로 그려져. REPORT_LINE은 둘 다 잡아. 그래서 빈 줄은 엔진이 검사하는 게 아니라 final_form에 적힌 지시야. 그리고 이 패턴이 하는 일은 아빠가 읽을 글에서 줄을 도려내는 것 하나뿐이야. 렌더러가 그린 줄을 잡아도 해가 없는 일이지.
일이 나뉘어 있는 걸 봐. 보고를 찾는 일이랑 아빠가 읽을 걸 정하는 일은 다른 함수야. report_spans는 숨었든 말든 coop_reports 리스트를 가진 JSON 객체를 전부 찾아. 그래서 터진 줄도 보고는 커밋돼. dad_text는 엔진이 마지막 답을 전달할 때 뭘 보여줄지 정해. 보고에 for_dad 줄이 있으면 그게 우선이고, FOR_DAD_LIMIT인 1,200자에서 잘려. 그게 없으면 산문에서 보고 구간, REPORT_LINE 줄, 빈 코드 펜스를 도려내. 알림 줄은 하나도 빠짐없이 이 함수를 거쳐. 워커가 뭘 붙였든 Coop이 직접 전하는 길로는 보고 JSON이 아빠한테 안 가. body는 영어 기록으로 남고.
닫힌 필드 집합
보고 자체에도 가드가 하나 더 있어. REPORT_FIELDS는 딱 binding_id, outcome, body, evidence, for_dad야. 다른 키가 하나라도 있으면, 남는 키만 조용히 버리고 받아주는 게 아니라 아예 잘못된 보고로 거절해. 오타 난 evidnce가 흔적 없이 사라지는 쪽이 거절보다 나빠. 워커는 자기 증거가 기록에 올라갔다고 믿을 테니까. 이 거절은 엔진한테 묻기 전에 어댑터에서 일어나. 그 자리가 치르는 대가는 이 트랙의 세 번째 레슨에서 봐.