본문 바로가기
C.W.K.
Stream
Lesson 01 of 05 · published

아무것도 안 그려지는 한 줄

~14 min · markdown, commonmark, reporting, parsing

Level 0메모 쪽지
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

독자는 둘, 마지막 답은 하나

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가 흔적 없이 사라지는 쪽이 거절보다 나빠. 워커는 자기 증거가 기록에 올라갔다고 믿을 테니까. 이 거절은 엔진한테 묻기 전에 어댑터에서 일어나. 그 자리가 치르는 대가는 이 트랙의 세 번째 레슨에서 봐.

텍스트 하나를 사람과 기계가 같이 읽는다면, 각자 읽을 층을 따로 주고, 기계가 판단하는 숨김을 렌더러의 숨김에 맞춰. 그리고 그래도 못 보는 게 뭔지 적어 둬. 데이터가 어디 있든 찾아내는 너그러운 파서는 좋아. 뭐가 안 보이는지를 너그럽게 판정하는 정규식은 정작 중요한 날 거짓말을 해.

Code

엔진에서 발췌, 일부 생략: 숨은 줄 패턴, 닫힌 필드 집합, 아빠가 읽는 것·python
# engine.py
REPORT_LINE = re.compile(r'^ {0,3}\[coop-[a-z]+\]:[ \t]*<[^<>\n]*>[ \t]*$', re.M)

def dad_text(text):
    """What Dad reads of one worker final: its for_dad lines, never report JSON."""
    spans=report_spans(text)
    lines=[r['for_dad'].strip() for _,_,reports in spans for r in reports
           if isinstance(r,dict) and isinstance(r.get('for_dad'),str) and r['for_dad'].strip()]
    if lines:
        return '\n'.join(lines)[:FOR_DAD_LIMIT]
    for start,end,_ in reversed(spans):
        text=text[:start]+text[end:]
    text=REPORT_LINE.sub('',text)
    text=re.sub(r'```[A-Za-z]*\s*```','',text)
    return re.sub(r'\n{3,}','\n\n',text).strip()

# adapters/host.py
REPORT_FIELDS={'binding_id','outcome','body','evidence','for_dad'}
직접 돌려볼 수 있는 스케치: 진짜 CommonMark 파서로 마지막 답 셋을 렌더링 (pip install markdown-it-py)·python
import json
from markdown_it import MarkdownIt

md = MarkdownIt("commonmark")
lines = "아빠, 끝났어. 커밋은 origin에 푸시됐어.\n아빠가 할 일은 없어."

def final(body, gap="\n\n"):
    report = {"coop_reports": [{"binding_id": "b1", "outcome": "done", "body": body,
                                "for_dad": lines, "evidence": {"commits": ["abc123"]}}]}
    return lines + gap + "[coop-report]: <" + json.dumps(report, ensure_ascii=False) + ">"

cases = {
    "clean": final("Hook covers ~/CWK/{repo}."),
    "angle bracket": final("Hook covers ~/CWK/<repo>."),
    "no blank line": final("Hook covers ~/CWK/{repo}.", gap="\n"),
}
for name, text in cases.items():
    html = md.render(text)
    print(f"{name:14} leaks JSON: {'coop_reports' in html}")

# clean          leaks JSON: False
# angle bracket  leaks JSON: True
# no blank line  leaks JSON: True

External links

Exercise

사람이 읽는 텍스트 안에 기계용 데이터를 넣는 도구를 하나 골라봐. CI 봇 댓글, 커밋 트레일러, 챗봇 답변 같은 거. 기계용 부분을 빈 줄 아래 링크 참조 정의 한 줄로 옮기고, CommonMark 파서로 렌더링해서 사람 눈엔 사람용 줄만 보이는지 확인해. 그다음 일부러 세 가지로 깨뜨려봐. 데이터 속 꺾쇠, 빠진 빈 줄, 닫는 꺾쇠 뒤에 남은 글자. 그리고 네 입력 모두에서 렌더러와 똑같이 판정하는 정규식을 짜봐.
Hint
눈 말고 명세의 문법에서 시작해. 라벨, 콜론, 공백(있어도 되고 없어도 돼), 줄바꿈도 이스케이프 안 된 꺾쇠도 없는 꺾쇠 속 목적지, 다시 공백, 줄 끝. 한 줄씩만 읽는 패턴은 빠진 빈 줄을 못 봐. 그러니 네 정규식은 윗줄까지 봐야 해. 어느 입력에서든 정규식이랑 렌더러 판정이 갈리면, 틀린 쪽은 정규식이야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.