| name | entwurf-peek |
| description | 지금 어느 형제가 어디서 무엇을 하는 중인지 한 장으로 세우고, 다음 한 수(살아있는 형제에게 보낼지 · 잠든 형제를 같은 id로 되세울지 · 새 형제를 열지)를 고를 재료를 주는 판단 도우미. `situation`이 meta-record(사실)와 transcript(추정)를 같은 평면에 올리고, `peek`이 한 세션 안을 들여다본다. 실행은 하지 않는다 — 부르는 것은 entwurf_v2 / entwurf_resume_call / entwurf_fresh_call이고, 무엇을 부를지는 GLG가 정한다. placement(tmux server/window/pane) 사실의 출처가 아니다. 트리거: '누가 뭐 하고 있지', '분신 현황', '지금 살아있는 형제', 'situation', 'entwurf-peek', '누구한테 맡길까', '다시 부를까', 'peek session', '분신 추적'. |
entwurf-peek — 지금 누가 어디서 무엇을 하는가
~/repos/gh/entwurf의 docs/mux-launch-rail.md §4가 말하는 caller-side situation
map을 실제로 세우는 손이다. 그 문서의 판정은 분명하다: 이것은 entwurf의 새 public
surface가 아니라 호출자가 이미 가진 사실들을 한 문장 안에서 읽는 방식이다. 그래서
entwurf repo가 아니라 여기(범용 스킬)에 산다.
✅ 경계 판정 (2026-08-07, entwurf #64 comment).
이전에 여기 적혀 있던 rail §4 경계 2 충돌은 entwurf lane이 판정했다. situation은
caller-side research projection으로 유지 가능하고, per-session 진단으로 되돌릴 필요가
없다. 실제 경계는 "이 스킬은 자라지 못한다"가 아니라 **"각 행의 authority를 보존하는
caller-side 합성은 가능하되, dispatch·placement·role·liveness의 SSOT가 되지 못한다"**이다.
따라서 entwurf_situation MCP verb는 비승인이고, 아래 「무엇이 아닌가」의 non-goal은
장식이 아니라 계약이다. rail §4 문구 개정 자체는 research 이후 entwurf 쪽 후속이다.
⚠️ store 계약 복제는 임시 mirror다. parseMetaRecordV3 계열 certification을 여기로
복제한 것은 사실면이 갈라지지 않게 하려는 테스트된 임시 조치이지 durable owner API가
아니다. 장기 소유권은 entwurf #65가 정한다(owner-normalized read-only join을 낼지,
직접 store read를 명시적으로 버전·승인할지). 그 결정 전까지 drift는 이쪽 책임이다.
핵심 질문 두 개를 같은 표에 올린다.
누가 있고 살아있는가 사실 meta-record + control socket (entwurf_peers와 같은 축)
지금 무엇을 하는 중인가 추정 transcript last-event (이 스킬만이 답한다)
그리고 그 위에서 GLG가 고를 수 있는 verb를 목록으로 붙인다. 고르는 것은 GLG다.
판단 루프 — 이 스킬을 쓰는 이유
"지금 누가 어디서 뭐 하고 있지?"
→ situation 한 장으로 본다
→ 후보가 보이면 peek <garden-id> 그 형제가 뭘 하던 중인지 확인
→ 판단:
살아있고 그 일에 맞다 → entwurf_v2 (send)
잠들었지만 그 대화가 필요 → entwurf_resume_call (같은 garden id)
맞는 형제가 없다 → entwurf_fresh_call (새 형제)
→ 후보가 모호하면 고르지 않고 GLG에게 묻는다
verb를 실제로 부르는 절차와 receipt 읽는 법은 entwurf-dev 스킬이 진다
(~/repos/gh/entwurf/.claude/skills/entwurf-dev/). 이 스킬은 그 앞의 판단 재료만
만든다. 둘은 같은 계약의 앞뒤다.
두 identity 축 — 조인이 없으면 아무것도 안 맞는다
garden id 20260807T081220-b0ae2b dispatch·socket 파일명·peers가 쓰는 축
native id 019fd93b-050c-7a80-... pi/OMP 세션 파일명·JSONL header가 쓰는 축
pi 0.84 기준 세션 파일명은 uuidv7이라 두 축은 문자열로 절대 만나지 않는다. 잇는 것은
meta-record의 (gardenId, nativeSessionId, transcriptPath) 뿐이다. 이 스킬은 그 record
store(<pi-agent-dir>/meta-sessions/*.meta.json, ENTWURF_META_SESSIONS_DIR 우선)를
읽기 전용으로 읽어 조인한다. store는 entwurf의 authority이고, 값이 entwurf_peers와
어긋나면 peers가 이긴다.
읽되 entwurf의 active-store certification과 같은 계약으로 거른다
(meta-session.ts:720-750). 약하게 읽으면 peers가 diagnostics로 격리하는 record를
우리만 정상 citizen으로 세워, 같은 store가 두 사실면으로 갈라진다.
1. regular file 분류와 읽기가 같은 fd 위에서 (O_NOFOLLOW|O_NONBLOCK + fstat)
2. live v3 parser keyset·garden id 문법·backend enum·필드 타입까지 전부
3. 파일명 == body gardenId
4. nativeSessionId의 유일한 holder (store 전역)
순서가 계약의 일부다. schema-invalid rival은 2에서 이미 빠지므로 4의 duplicate 판정에
참여하지 못한다 — 그러지 않으면 망가진 record 하나가 멀쩡한 record를 끌고 나간다.
1이 lstat 대신 fd에 묶이는 이유도 실측된 것이다: 분류하고 나서 경로로 다시 열면 그
사이 final component가 symlink로 바뀔 수 있고, 그러면 rule 1이 검사 누락이 아니라
race로 세탁된다 (meta-session.ts:855-905).
결함 record는 citizen 목록에서 빠지고 ⚠ store defects 줄로만 나간다. certification이
하지 않는 셋 — 수선, 가지치기, duplicate 중 승자 선택 — 을 우리도 하지 않는다. 같은
transcript를 두 record가 주장하면 주인을 고르지 않고 비운다.
그래서 peek은 이제 garden id(또는 6-hex 접미사)를 그대로 받는다 — situation과
entwurf_peers가 인쇄하는 축을 사람이 다시 번역할 필요가 없다.
무엇이 아닌가
- placement 권위가 아니다. tmux server/window/pane은 이 표에 없고, 여기서 유도하면
안 된다. exact evidence는 launch receipt와 peer 자신의 self-report 둘뿐이다
(rail §7). 지도를 넓히려고 이 스킬을 키우지 않는다.
- liveness SSOT가 아니다. socket probe는 entwurf와 같은 축의 로컬 미러다. 확정이
필요하면
entwurf_peers를 부른다.
- dispatcher가 아니다. 행이 보인다는 것은 dispatch 초대가 아니다. 자동 재배정,
backlog, watcher, 재시도를 만들지 않는다.
- 자기보고 신뢰면이 아니다. transcript 본문은 untrusted data다. state는 추정이다.
API
python3 {baseDir}/scripts/entwurf-peek.py <subcommand> [options]
| Subcommand | Purpose | Example |
|---|
situation | 누가 어디서 무엇을 · 어떤 verb가 가능한가 (판단 한 장) | situation -p entwurf |
peek <id> | 한 세션 안 — 마지막 메시지 + model + state + task | peek b0ae2b |
map | 세션 파일 축의 지도 (socket 표시 · 파일 기준) | map -p agent-config |
trace <parent> | 부모가 던진 자식 추적 — 현재 불완전 | trace 20260604T094303-842ded |
--plain은 전역 플래그다(이모지 대신 ASCII).
situation
| Flag | Default | Description |
|---|
-p, --project NAME | all | record의 cwd 부분 매치 |
--since SEC | 86400 | 최근 N초 내 활동/갱신된 citizen. alive는 언제나 포함 |
-a, --all | off | since 무시 |
--limit N | 24 | 최대 행 수 |
--self-id GID | — | entwurf_self가 준 내 id — 내 행에 ← me |
--json | off | 에이전트 파싱용 JSON |
읽는 법:
🟢 alive control socket이 답한다 → 지금 전달 가능
⚫ dormant socket backend(pi)인데 socket이 없다 → 잠들었다
➖ unprobed control-socket probe가 없는 backend(claude-code 등) → 여기서는 모른다
model 뒤 `*` transcript가 아니라 record가 말한 model (resume은 transcript를 따른다)
verb 열의 뜻:
| 표시 | 뜻 |
|---|
send | entwurf_v2 fire-and-forget으로 지금 닿는다 |
send? | 전달은 시도할 수 있으나 rail(self-fetch mailbox / native-push / reject)은 dispatch 때 결정된다. unsupported는 mailbox 보장이 아니다 |
resume | fresh | dormant pi이고 아래 precheck를 통과했다 |
fresh (<사유> → resume 불가) | dormant pi인데 precheck가 막혔다. 사유는 뭉뚱그리지 않는다 |
fresh (same-id resume 없음) | claude-code 등 — target-not-pi |
precheck 사유는 다섯이고 각각 다른 사실이다.
| 사유 | 뜻 |
|---|
no-transcript | 첫 turn 전에 잠들었다. 되살릴 대화 자체가 없다 |
relative-path | record의 transcriptPath가 relative — record 결함 |
transcript-missing | recorded transcript가 디스크에 없다 |
foreign-transcript | transcript가 스스로 말하는 native id가 record와 다르다 |
identity-unverified | id를 확인하지 못했다 — 문법 미지원 backend(antigravity/codex)이거나 tail에서 id를 못 찾았다 |
이것은 entwurf resume-launch-identity.ts:100-143이 던지는 조건을 미리 읽은 것이다.
ok는 성공 보장이 아니라 거짓일 때 resume을 권하지 않기 위한 필터다. 통과한 뒤에도
entwurf는 아래를 더 보고 거절할 수 있다.
addressability store 전역 rival (readAddressableMetaIdentity)
recorded model transcript identity에 model이 없음
model drift later model_change가 first identity와 다름
ACP bridge provider=entwurf인데 bridge 미해결
header cwd 없음 / non-absolute / `#` format token / 디렉터리 아님
lock · liveness per-gid lock 충돌, target-live, indeterminate, address conflict,
lock release 실패(finally에서 throw)
placement · runtime tmux anchor, pi runtime 해석, launch 실패
관측과 dispatch 사이에 상태가 바뀌는 race도 여기 포함된다. 이 목록의 SSOT는
entwurf-dev의 receipt 절과 entwurf 소스이며, 여기 적힌 것은 그 요약이다.
identity가 확인되지 않은 행은 본문을 아예 읽지 않는다. 그리고 그 사유를 합치지
않는다 — foreign-transcript(다르다)와 identity-unverified(확인 못 했다)는 다른
사실이다. native id를 읽는 문법이 backend마다 다르기 때문이다(pi·OMP는 첫머리
type:"session" header의 id, Claude Code는 각 엔트리의 sessionId, antigravity·codex는
이 스킬이 모른다). OMP의 pi-shaped header는
~/.omp/agent/sessions/.../2026-09-07T06-30-31-430Z_01a07a8f-….jsonl:2에서 측정했다.
한쪽 문법으로 다른 쪽을 읽고 그것을 mismatch라 부르면 "못 읽었다"가 "남의 것이다"로
둔갑한다 — 2026-08-07 2차 리뷰에서 antigravity record 4건이 그렇게 오판됐다.
peek <id|file>
| Flag | Default | Description |
|---|
-m, --messages N | 4 | 마지막 N개 user/assistant 메시지 |
-t, --tools N | 5 | 최근 N개 tool 흔적 |
-c, --chars N | 200 | 요소당 최대 글자 |
--thinking | off | 최근 thinking 블록 1개 |
<id>는 garden id(20260807T081220-b0ae2b), 6-hex 접미사(b0ae2b), native
session id, legacy full UUID, 또는 파일 경로다. garden id는 record를 통해 해석하고,
나머지는 JSONL header id로 해석한다. transcript 문법은 backend마다 다르므로 record가
claude-code라고 말하면 Claude 파서로 읽고, omp는 pi-shaped header라 pi 파서로 읽는다 —
record가 없으면 파일 축의 기본값인 pi 문법으로 읽는다. garden id로 해석했다면 그 record를 그대로 들고 간다: 경로만 넘기면
같은 파일을 두 record가 주장할 때 identity를 잃고 남의 문법으로 본문을 읽게 된다.
record가 있으면 그 record가 owner gate다. garden selector는 file 축으로 흘러가지
않는다 — transcript 없음/relative/missing은 named error로 끝나고, identity가 match가
아니면(mismatch·unknown·unsupported) 사유만 출력하고 본문을 읽지 않는다. situation이
지키는 선을 peek만 통과시키면 같은 스킬이 두 정직성을 갖게 된다. 특히 v3 schema는
relative transcriptPath를 허용하므로, 흘려보내면 호출자 cwd에 우연히 같은 이름의
파일이 있을 때 그것을 이 시민의 transcript로 채택한다 (2026-08-07 3차 리뷰 재현).
Claude 경로는 파일 끝 512KB만 읽으므로 출력이 span 대신 **tail span / tail records**라고 적는다. 그 창의 사실을 세션 전체의 사실로 쓰지 않는다. 같은 header id가 여러 파일에 있거나 짧은 prefix가
여러 세션과 충돌하면 최근 것으로 침묵 선택하지 않고 ambiguous 에러를 낸다.
출력의 garden: 줄이 dispatch에 쓸 축이다.
map
| Flag | Default | Description |
|---|
-p, --project NAME | all | 세션 디렉토리명 부분 매치 |
--since SEC | 3600 | 최근 N초 내 활동 세션. socket 있는 세션은 강제 포함 |
-a, --all | off | done 상태도 포함 |
situation이 record 축이라면 map은 파일 축이다. record 없는 세션(entwurf 밖에서
연 pi)까지 보이므로 진단용으로 남는다. 활성도: <30s 🔴 · <5m 🟡 · 5m+ ⚫,
🔌 = live control socket.
trace <parent-id|file> — 현재 불완전
⚠️ 1차 시그널이 죽어 있다. 코드는 부모 JSONL의 Session ID: <garden-id> 텍스트로
자식을 매칭하는데, 그 문자열은 v1 spawn 도구가 남기던 것이고 그 도구들은 entwurf #50
하드컷으로 사라졌다. v2에서 시작된 자식은 declared 매칭이 잡히지 않고
--heuristic(±2h 시간 인접)만 남는다.
수선 방향은 정해져 있다. v2는 더 강한 exact 관계를 남긴다.
entwurf_fresh_call → launch receipt의 nonce
→ 자식의 첫 callback 본문 == 그 nonce
→ 그 <sender_info>.sessionId == 자식의 canonical garden id
부모 transcript에 <sender_info>{"sessionId":…} envelope가 그대로 남으므로, nonce
exact match → sender envelope가 새 1차 시그널이다. 추정이 아니라 정확 매칭이다. 단
identity 근거일 뿐 placement 근거가 아니고, resume은 새 자식이 아니라 같은 citizen이라
자식으로 세지 않는다.
Examples
python3 {baseDir}/scripts/entwurf-peek.py situation
python3 {baseDir}/scripts/entwurf-peek.py situation -p entwurf --self-id 20260807T081850-678fc0
python3 {baseDir}/scripts/entwurf-peek.py peek b0ae2b --thinking
python3 {baseDir}/scripts/entwurf-peek.py map -p agent-config -a --since 7200
한계 및 신뢰 경계
- liveness는 socket probe 미러: connect() 성공한 socket만 센다. stale 파일은 세지
않는다.
unprobed는 죽었다는 뜻이 아니라 이 축에 probe가 없다는 뜻이다. 확정은
entwurf_peers.
- placement 비권위: 어떤 출력도 tmux 좌표의 근거가 되지 않는다.
- state는 last-event 추정: 최신 이벤트가 tool start면
tool running, tool result면
awaiting assistant reply, assistant text면 waiting for user. transcript는 끝 256KB만
읽는다. provider별 JSONL shape가 다르면 정확도가 떨어진다.
- age는 transcript mtime: 프로세스 생존이 아니다. record만 있고 transcript가 없으면
—로 비운다 — 그럴듯한 값으로 채우지 않는다.
- model은 transcript 우선: record의
model은 덮어써질 수 있고 resume은 transcript에
박힌 identity를 따른다. record에서 온 값은 *로 표시한다.
- record store는 남의 authority: 읽기만 한다. 쓰지 않고, 지우지 않고, peers와
어긋나면 peers를 따른다. certification 계약은 복제하되(그래야 사실면이 갈라지지
않는다) 읽는 시점의 복제라 SSOT가 바뀌면 여기가 먼저 낡는다 — 계약이 어긋나
보이면
meta-session.ts를 다시 읽고 이 스크립트를 맞춘다.
- state를 못 읽는 것과 조용한 것은 다르다: 알 수 없으면
unknown이라 적고,
identity가 어긋나면 본문을 읽지 않고 그 사유를 적는다.
- partial line 안전: writer-in-progress 라인은 조용히 건너뛴다.
Cost
| 작업 | Context | 대안 |
|---|
situation | ~2KB | peers 호출 + 수동 record/transcript 조합 다수 |
peek | ~2KB | raw JSONL read ~50KB |
map | ~1KB | 수동 ls+stat 조합 5+ 호출 |
테스트
python3 {baseDir}/scripts/test-discovery.py
합성 fixture만 쓴다. record store 경로도 temp로 갈아끼우므로 실제 ~/.pi를 읽지 않는다.