Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/plan-fae58b93.md
T
Godopu c3631e2aa1 fix(herdr): resolve shim routing defects, add workspace scoping, and bump to v3.0.1
- Resolve Herdr shim 5 routing & paste defects (ISSUE-1 ~ ISSUE-5):
  * paste-buffer: use pane send-text without auto-enter, propagate rc=3 to send_keys_safe
  * exact-match pane resolution: remove substring matching ('in tn') across all branches
  * workspace scoping: introduce HERDR_WORKSPACE_ID and .mam/herdr_workspace_id persistence
  * unified resolver: single _resolve_herdr_pane_id helper across shim commands
- Resolve dialog token false-positive on 'Yes, try it' tip and isolate fullscreen modal rejection
- Sync mock Herdr CLI contracts in tests/conftest.py
- Add contract tests H-15~H-23 and regression tests D-4~D-7 (412 tests, 100% PASS)
- Add multi-agent loop plans, review reports, and bug report
- Update framework and skill packages to v3.0.1
2026-08-27 21:42:37 +09:00

509 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 📐 구현 계획서: Herdr 셈(shim) 패인 라우팅 결함 4종 수정 (ISSUE-1/2/3/5)
- **Job ID**: `fae58b93`
- **Role**: Planner (`planner-reviewer-claude-01`)
- **작성일**: 2026-08-27
- **대상**: `.agents/skills/lib.sh`, `tests/conftest.py`, `tests/test_herdr_shim_contract.py`, `tests/test_b19_headless_reconcile_fixes.py`
- **근거 문서**: `bug_report.md` (v1.0)
- **기준 커밋**: `4bbd03b` (main)
---
## 0. 요약 (TL;DR)
`bug_report.md`의 5대 결함 중 ISSUE-4는 이미 커밋 `4bbd03b`에서 해결되어 회귀 테스트(`test_agent_start_success_tokens_exclude_startup_timeout`)로 고정되어 있다. 남은 **ISSUE-1 / 2 / 3 / 5**를 다음 순서로 처리한다.
1. **ISSUE-5 선행** — 셈 내부에 공용 헬퍼 `_resolve_herdr_pane_id`를 신설한다. 나머지 3개 이슈의 수정이 전부 이 헬퍼 안으로 수렴하므로 이것이 반드시 먼저다.
2. **ISSUE-2** — 헬퍼 및 `_resolve_herdr_target` / `has-session`에서 `agent in tn` 부분 매칭을 전면 제거하고 엄격 일치로 대체.
3. **ISSUE-3**`HERDR_WORKSPACE_ID`**명시적으로 설정된 경우에만** `pane list --workspace`로 하드 스코핑.
4. **ISSUE-1**`paste-buffer``pane send-text` 단독 삽입으로 교체(엔터 금지), 해결 실패 시 조용히 삼키지 말고 실패를 상위로 전달.
---
## 1. 사전 조사에서 확인된 사실 (계획의 전제)
계획 수립 중 실제 `herdr` 바이너리(`/opt/homebrew/bin/herdr`)와 현재 `lib.sh`를 직접 검증했다. **버그 리포트의 권고 코드를 그대로 옮기면 안 되는 지점이 3곳** 있다.
### 1.1 ✅ `herdr agent send` 서브커맨드는 존재하지 않는다 (ISSUE-1의 진짜 뿌리)
```
$ herdr agent --help
Commands: list get read send-keys prompt rename focus wait attach start explain
```
현재 `lib.sh:822``paste-buffer` 구현은 다음 한 줄이 전부다.
```bash
_real_herdr agent send "$sess" "$(cat "$buffer_dir/$buf")" >/dev/null 2>&1 || true
```
`agent send`는 CLI에 없으므로 이 호출은 **항상 실패하고 `|| true`가 실패를 삼킨다**. 즉 현재 `main`에서 `paste-buffer` 경로는 텍스트를 단 한 글자도 주입하지 못하는 완전한 데드 코드다. 이것이 브리프의 "`paste-buffer``herdr agent send` 부재"가 가리키는 실체이며, `send_keys_safe`의 폴백 경로 전체가 무력화되어 있음을 뜻한다.
> 참고: `send_keys_safe`는 `agent prompt` 고속 경로가 성공하면 즉시 반환하므로(`lib.sh:1785`), **등록된 agent에 대해서는** 이 결함이 드러나지 않는다. 결함이 표면화되는 조건은 정확히 버그 리포트가 기술한 상황 — `agent prompt`가 실패하는 **라벨 전용 패인(agent 미등록)** — 이다.
### 1.2 ⚠️ 버그 리포트의 `pane_id` 정규식은 실제 pane_id를 거부한다
버그 리포트 §3.1은 다음 검증을 제안한다.
```bash
if [[ "$pid" =~ ^w[0-9]+:p[0-9]+$ ]]; then
```
그러나 실제 서버가 반환하는 pane_id는 다음과 같다.
```json
{"pane_id":"w1E:p1","workspace_id":"w1E","tab_id":"w1E:t1", ...}
```
워크스페이스 세그먼트는 `w1E`처럼 **영문자를 포함**한다. 권고 정규식을 그대로 쓰면 모든 실제 pane_id가 거부되어 헬퍼가 항상 실패하고, 결과적으로 ISSUE-1을 고친 뒤에도 주입이 되지 않는다.
**채택 정규식**: `^w[A-Za-z0-9]+:p[A-Za-z0-9]+$`
### 1.3 ⚠️ 실제 `pane list` 응답에는 `label` 키가 없을 수 있다
```json
{"agent":"claude","agent_status":"working","cwd":"...","pane_id":"w1E:p1",
"tab_id":"w1E:t1","terminal_title":"...","workspace_id":"w1E"}
```
`label``herdr pane rename <PANE_ID> <LABEL>`로 설정했을 때만 나타난다. 반면 `agent list`에는 `name` 필드가 있다(`"name":"planner-reviewer-claude-01"`). 따라서 헬퍼의 매칭 우선순위는 `label``name``agent` 순으로 두되, **셋 다 완전 일치만** 허용한다. `agent` 필드는 사실상 CLI 종류(`claude`/`grok`)이므로 `tn`이 그 값과 완전히 같은 경우에만 매칭되며, 이는 부분 매칭과 달리 오라우팅을 만들지 않는다.
### 1.4 ✅ `pane list`는 서버측 `--workspace` 필터를 지원한다
```
$ herdr pane list --help
Options:
--workspace <WORKSPACE_ID>
```
ISSUE-3의 스코핑은 파이썬 클라이언트 필터링만이 아니라 **서버측 플래그로 1차 차단**할 수 있다. 양쪽 모두 적용한다(플래그 미지원 구버전 herdr 대비 이중 방어).
### 1.5 ✅ `pane read`와 `agent read`의 출력 형식은 호환된다
둘 다 평문 텍스트를 반환하며, `_pane_capture`(`lib.sh:1672`)는 JSON 파싱 실패 시 원문을 그대로 반환하므로 `capture-pane``pane read`로 전환해도 상위 로직이 깨지지 않는다.
### 1.6 ⚠️ 셈은 `set -euo pipefail` 아래에서 실행된다
셈 본문은 `lib.sh:141``cat <<'EOF'` ~ `lib.sh:959``EOF` 사이 히어독으로 생성되며 3번째 줄이 `set -euo pipefail`이다. 따라서 실패를 반환할 수 있는 새 헬퍼는 **모든 호출부에서 `|| true`로 감싸야** 하며, 그렇지 않으면 셈이 조기 종료된다.
### 1.7 ✅ 테스트 목(mock)이 결함을 은폐하고 있다
`tests/conftest.py:638`의 목 herdr는 존재하지 않는 `agent send`를 **성공으로 처리**한다. 이 때문에 ISSUE-1이 테스트에서 전혀 드러나지 않았다. 목을 실제 CLI 계약에 맞추는 것이 이번 작업의 필수 선행 조건이다.
---
## 2. 변경 대상 목록
| # | 파일 | 위치 | 이슈 | 성격 |
|---|---|---|---|---|
| C1 | `.agents/skills/lib.sh` | 셈 히어독, `_sanitize_herdr_agent_name` 직후 (~L236) | 5 | 신규 헬퍼 `_resolve_herdr_workspace_scope`, `_resolve_herdr_pane_id` |
| C2 | `.agents/skills/lib.sh` | `_resolve_herdr_target` (L249286) | 2,3 | 부분 매칭 제거 + ws 필터 |
| C3 | `.agents/skills/lib.sh` | `has-session` (L305345) | 2,3,5 | 부분 매칭 제거 + ws 필터 + 헬퍼 폴백 |
| C4 | `.agents/skills/lib.sh` | `new-session` (L434530) | 3 | 해결된 workspace_id를 `HERDR_WORKSPACE_ID`로 export |
| C5 | `.agents/skills/lib.sh` | `kill-session` (L567603) | 5 | 인라인 파서 → 헬퍼 |
| C6 | `.agents/skills/lib.sh` | `capture-pane` (L687704) | 3,5 | 헬퍼 + `pane read` 경로 |
| C7 | `.agents/skills/lib.sh` | `send-keys` (L705744) | 2,3,5 | 인라인 파서 → 헬퍼 |
| C8 | `.agents/skills/lib.sh` | `paste-buffer` (L794827) | 1,5 | `agent send``pane send-text`, 엔터 금지, 실패 전파 |
| C9 | `.agents/skills/lib.sh` | `send_keys_safe` (L18041806) | 1 | `paste-buffer` 종료 코드 확인 → rc 3 |
| C10 | `tests/conftest.py` | 목 herdr | 1,2,3 | `pane send-text`/`pane read`/`pane rename` 추가, `pane list` 병합·라벨·`--workspace`, `agent send` 제거 |
| T1 | `tests/test_herdr_shim_contract.py` | 신규 | 1,2,3,5 | H-15 ~ H-20 |
| T2 | `tests/test_b19_headless_reconcile_fixes.py` | 신규 | 1,2,5 | D-4 ~ D-7 |
> `list-panes`(L605686)의 인라인 파서는 `pane_id` 외에 `cwd`/`agent`까지 한 번에 파싱하므로 헬퍼로 대체하지 **않는다**. ISSUE-5의 대상 목록에도 포함되어 있지 않다.
---
## 3. 상세 구현 설계
### 3.1 [C1] 공용 헬퍼 신설 (ISSUE-5)
`lib.sh` 셈 히어독 내부, `_sanitize_herdr_agent_name` 정의 직후(`cmd="${1:-}"` 앞)에 삽입한다. 이 위치여야 `case` 분기 전체에서 참조 가능하다.
```bash
# ---------------------------------------------------------------------------
# Workspace scoping (ISSUE-3).
#
# 스코핑은 HERDR_WORKSPACE_ID 가 "명시적으로" 설정된 경우에만 하드 필터로
# 동작한다. cwd 로부터 자동 추론하지 않는다 — 자동 추론은 다중 워크스페이스
# 오케스트레이션에서 정당한 교차 워크스페이스 조회를 조용히 막아버린다.
# 미설정 시에는 서버 전역 조회(기존 동작)를 유지한다.
# ---------------------------------------------------------------------------
_herdr_ws_scope() { printf '%s\n' "${HERDR_WORKSPACE_ID:-}"; }
# _resolve_herdr_pane_id <target> [workspace_id]
#
# 세션 이름 / 라벨을 실제 pane_id ("wN:pM") 로 해석한다.
# 엄격한 해석 순서 (부분 문자열 매칭은 어느 단계에서도 사용하지 않는다):
# 1. herdr agent get <sanitized_name>
# 2. herdr agent get <raw_name>
# 3. herdr pane list [--workspace WS] 에서
# 3-a. label 완전 일치
# 3-b. name 완전 일치
# 3-c. agent 완전 일치
# 성공 시 pane_id 를 stdout 에 출력하고 0, 실패 시 아무것도 출력하지 않고 1.
# 호출부는 반드시 `|| true` 로 감쌀 것 (셈은 set -e 하에서 동작한다).
_resolve_herdr_pane_id() {
local target="$1"
local target_ws="${2:-$(_herdr_ws_scope)}"
local sat pid=""
sat=$(_sanitize_herdr_agent_name "$target")
local cand
for cand in "$sat" "$target"; do
[ -n "$cand" ] || continue
pid=$(_real_herdr agent get "$cand" 2>/dev/null | TARGET_WS="$target_ws" python3 -c "
import sys, json, os
tws = os.environ.get('TARGET_WS', '')
try:
a = json.load(sys.stdin).get('result', {}).get('agent', {})
# ISSUE-3: 워크스페이스가 지정되면 다른 워크스페이스의 동명 agent 는 거부.
if tws and a.get('workspace_id') and a.get('workspace_id') != tws:
pass
else:
print(a.get('pane_id') or '')
except Exception:
pass
" 2>/dev/null || echo "")
[ -n "$pid" ] && break
done
if [ -z "$pid" ]; then
local ws_flag=()
[ -n "$target_ws" ] && ws_flag=(--workspace "$target_ws")
pid=$(_real_herdr pane list "${ws_flag[@]+"${ws_flag[@]}"}" 2>/dev/null \
| TARGET_NAME="$target" TARGET_SAN="$sat" TARGET_WS="$target_ws" python3 -c "
import sys, json, os
tn = os.environ.get('TARGET_NAME', '')
tsa = os.environ.get('TARGET_SAN', '')
tws = os.environ.get('TARGET_WS', '')
try:
panes = json.load(sys.stdin).get('result', {}).get('panes', [])
# 서버가 --workspace 를 무시하는 구버전일 수 있으므로 클라이언트에서 한 번 더 거른다.
if tws:
panes = [p for p in panes if p.get('workspace_id') == tws]
# ISSUE-2: 완전 일치만 허용. 'agent in tn' 부분 매칭은 사용하지 않는다.
for key in ('label', 'name', 'agent'):
for p in panes:
v = p.get(key)
if v and (v == tn or v == tsa):
pid = p.get('pane_id') or ''
if pid:
print(pid)
sys.exit(0)
except Exception:
pass
sys.exit(1)
" 2>/dev/null || echo "")
fi
# 실제 pane_id 는 'w1E:p1' 처럼 워크스페이스 세그먼트에 영문자를 포함한다.
# ^w[0-9]+:p[0-9]+$ 로 좁히면 모든 실제 pane_id 가 거부된다.
if [[ "$pid" =~ ^w[A-Za-z0-9]+:p[A-Za-z0-9]+$ ]]; then
printf '%s\n' "$pid"
return 0
fi
return 1
}
```
**설계 근거**
- **`for key in ('label','name','agent')` 바깥 루프**: 우선순위가 "패인 목록의 등장 순서"가 아니라 "필드의 신뢰도"로 결정된다. 안쪽/바깥쪽 루프를 뒤집으면 목록 첫 항목의 `agent` 매칭이 뒤쪽 항목의 정확한 `label` 매칭을 이겨버린다 — 이것이 ISSUE-2가 만든 오라우팅과 동일한 형태의 버그다.
- **`tsa`(sanitized) 도 비교 대상에 포함**: `agent start`가 이름을 sanitize해서 등록하므로, 라벨은 원본이고 등록명은 sanitize본인 혼재 상황을 커버한다. sanitize는 결정적 함수이므로 부분 매칭과 달리 충돌을 만들지 않는다.
- **`ws_flag` 배열 + `${ws_flag[@]+...}`**: `set -u` 하에서 빈 배열 전개가 unbound 오류를 내지 않도록 하는 표준 관용구.
### 3.2 [C2] `_resolve_herdr_target` 엄격화 (ISSUE-2, ISSUE-3)
`lib.sh:262275`의 파이썬 블록에서 다음 술어를 제거한다.
```python
if name == tn or (not name and agent and agent in tn): # ← 제거
```
교체:
```python
tn = os.environ.get("TARGET_NAME", "")
tsa = os.environ.get("TARGET_SAN", "")
tws = os.environ.get("TARGET_WS", "")
...
agents = d.get("result", {}).get("agents", [])
if tws:
agents = [a for a in agents if a.get("workspace_id") == tws]
for a in agents:
name = a.get("name", "")
if name and (name == tn or name == tsa):
print(a.get("pane_id") or name)
sys.exit(0)
sys.exit(1)
```
`pane_id or agent``pane_id or name`으로 바꾼다. 기존 코드는 매칭에 실패한 항목의 CLI 종류(`agent`, 예: `"claude"`)를 타깃으로 반환할 수 있었는데, 이는 `agent prompt claude ...`처럼 전혀 다른 대상에게 프롬프트를 던지는 경로다.
### 3.3 [C3] `has-session` 엄격화 + 패인 폴백 (ISSUE-2, ISSUE-3, ISSUE-5)
`lib.sh:334`의 다음 술어를 제거한다.
```python
or (not an and a.get("agent") and a.get("agent") in tn) # ← 제거
```
남는 조건은 `an == tn or an == stn`이며, 여기에 `HERDR_WORKSPACE_ID` 필터를 추가한다.
그리고 agent 조회가 모두 실패했을 때 마지막 단계로 헬퍼를 호출한다.
```bash
if [ -n "$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)" ]; then
exit 0
fi
exit 1
```
**의도적 동작 변경**: 라벨만 붙은(agent 미등록) 패인도 이제 "세션 존재"로 판정된다. 이것이 정확히 버그 리포트가 보고한 실패 시나리오(`label: reviewer-cline-01` 패인에 주입 불가)의 해소 조건이다. `_resolve_herdr_pane_id`가 완전 일치만 허용하므로, 세션 이름 `reviewer-creator-grok-01``agent == "grok"` 패인에 매칭될 일은 없다.
**리스크**: `create_session.sh` / `reconcile.sh``has-session` 결과로 재생성 여부를 판단한다면, 라벨만 있고 실제 CLI가 죽은 패인을 "살아 있음"으로 오판할 수 있다. → 3.9의 회귀 검증 범위에 `test_orc_onboard.py`, `test_tier3_integration.py`, `test_tier4_e2e.py`를 명시적으로 포함한다.
### 3.4 [C4] `HERDR_WORKSPACE_ID` 전파 (ISSUE-3)
`new-session` 분기에서 `existing_ws` 또는 신규 `ws_id`가 확정된 직후(`lib.sh:509` 이후 `ws_id` 확정 지점) 다음을 추가한다.
```bash
if [ -n "${ws_id:-}" ]; then
export HERDR_WORKSPACE_ID="$ws_id"
fi
```
또한 `agent start`에 전달하는 `env_flags``--env HERDR_WORKSPACE_ID=$ws_id`를 추가하여, 기동된 에이전트 프로세스가 상속한 셈 호출부터 자동으로 스코프가 걸리도록 한다.
**채택하지 않은 대안**: 셈이 `$PWD`/`$WORKSPACE_ROOT`의 cwd로부터 workspace_id를 자동 추론하는 방식. 추론이 성공하는 순간 교차 워크스페이스 조회가 **조용히** 막히고, 오케스트레이터가 다른 워크스페이스의 에이전트를 정당하게 다루는 경로가 원인 불명으로 깨진다. 스코핑은 명시적 옵트인이어야 진단 가능하다.
### 3.5 [C5]~[C7] 분기 리팩터링 (ISSUE-5)
**`kill-session`** — `lib.sh:587598`의 이중 인라인 파이썬을 삭제.
```bash
agent_target=$(_sanitize_herdr_agent_name "$sess")
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -n "$pane_id" ]; then
_real_herdr pane close "$pane_id" >/dev/null 2>&1 || true
fi
_real_herdr kill-session -t "$agent_target" >/dev/null 2>&1 \
|| _real_herdr kill-session -t "$sess" >/dev/null 2>&1 || true
```
**`capture-pane`** — 헬퍼로 pane_id를 얻으면 `pane read`, 아니면 기존 `agent read` 체인 유지.
```bash
agent_target=$(_sanitize_herdr_agent_name "$sess")
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -n "$pane_id" ]; then
_real_herdr pane read "$pane_id" --source visible --lines 100 2>/dev/null || true
else
_real_herdr agent read "$agent_target" --source visible --lines 100 2>/dev/null \
|| _real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true
fi
```
**`send-keys`** — `lib.sh:726738`의 이중 인라인 파이썬을 삭제하고 헬퍼 호출로 대체. 폴백(`pane send-keys "$agent_target"``"$sess"`)은 그대로 유지한다. `C-m``Enter` 정규화(L741–743)도 유지 — 이건 키 이름 번역이지 제출 정책이 아니다.
**ISSUE-5의 "데드 파이프라인" 부분**: 기존 인라인 파서는 `except: pass`로 항상 exit 0을 반환해 `||` 2차 폴백이 절대 실행되지 않았다. 신규 헬퍼는 `sys.exit(1)` + 정규식 검증 + `return 1`로 실패를 정확히 신호하므로 이 데드 코드가 구조적으로 제거된다.
### 3.6 [C8] `paste-buffer` 재작성 (ISSUE-1)
```bash
buffer_dir="${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/buffers}"
buffer_dir="${buffer_dir:-${TMPDIR:-/tmp}/mam_buffers}"
if [ ! -f "$buffer_dir/$buf" ]; then
echo "Error: buffer $buf not found ($buffer_dir/$buf)" >&2
exit 1
fi
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -z "$pane_id" ]; then
# herdr 에는 `agent send` 서브커맨드가 없다. 여기서 조용히 성공을 반환하면
# send_keys_safe 가 아무것도 붙여넣지 않은 채 Enter 만 치게 된다.
echo "Error: paste-buffer could not resolve a pane for '$sess'" >&2
exit 1
fi
# 삽입 전용. Enter/C-m 제출은 전적으로 send_keys_safe 가 통제한다 (ISSUE-1).
# 여기서 `pane run` 을 쓰면 안 된다 — 텍스트와 Enter 를 한 번에 보내 이중 제출이 된다.
if ! _real_herdr pane send-text "$pane_id" "$(cat "$buffer_dir/$buf")" >/dev/null 2>&1; then
echo "Error: pane send-text failed for '$sess' ($pane_id)" >&2
exit 1
fi
```
**불변식 (테스트로 고정)**: `paste-buffer` 분기 본문에는 `Enter`, `C-m`, `pane run`, `agent prompt` 중 어떤 것도 등장하지 않는다.
### 3.7 [C9] `send_keys_safe`의 붙여넣기 실패 전파 (ISSUE-1)
현재 `lib.sh:18041806``paste-buffer`의 종료 코드를 버린다. 그리고 세션 이름에 `cline|claude|agy|grok`이 포함되면 붙여넣기 가시성 검증마저 건너뛴다(L1809–1812) — 즉 **실제 운영 대상 전부**에서 실패가 무성으로 삼켜진다.
```bash
_sks_herdr set-buffer -b "$sks_buf" "$text"
local _paste_rc=0
_sks_herdr paste-buffer -b "$sks_buf" -t "$sess" || _paste_rc=$?
_sks_herdr delete-buffer -b "$sks_buf" 2>/dev/null || true
if [ "$_paste_rc" != "0" ]; then
echo "send_keys_safe: paste-buffer failed rc=$_paste_rc ($sess)" >&2
return 3
fi
```
버퍼 정리(`delete-buffer`)는 조기 반환 **앞**에 둔다. 그렇지 않으면 실패 경로마다 버퍼가 누수되어 `set-buffer`의 A-3 GC 주석이 방어하는 바로 그 문제가 재발한다.
기존 반환 코드 계약(`3 = paste not visible`)을 재사용하므로 호출자 계약은 바뀌지 않는다.
### 3.8 [C10] 테스트 목(mock) 정합화 — `tests/conftest.py`
테스트 코드보다 **먼저** 처리해야 한다. 목이 실제 CLI와 어긋나 있는 한 어떤 테스트도 결함을 재현할 수 없다.
| 변경 | 위치 | 내용 |
|---|---|---|
| M1 | `cmd1 == "agent"`, `cmd2 == "send"` (L638664) | **핸들러 삭제** → 실제 CLI처럼 unknown subcommand로 exit 1. ISSUE-1 재현의 필수 조건 |
| M2 | `cmd1 == "pane"` | `send-text` 핸들러 추가: pane_id로 대상 조회, `sent_text` 누적, `buffer` 갱신, `sent_keys`**건드리지 않음** |
| M3 | `cmd1 == "pane"` | `read` 핸들러 추가: 대상 패인의 `buffer` 평문 출력 |
| M4 | `cmd1 == "pane"` | `rename` 핸들러 추가: `state["panes"]`의 해당 항목에 `label` 기록 |
| M5 | `pane list` (L253281) | 현재는 agents가 하나라도 있으면 `state["panes"]`를 **무시**한다. → agent 유래 패인과 `state["panes"]``pane_id` 기준으로 병합(dedupe)하고, `label`/`name` 필드를 그대로 실어 보낸다. 라벨 전용 패인 시나리오가 이 변경 없이는 표현 불가 |
| M6 | `pane list` | `--workspace` 필터는 이미 구현되어 있음(L255–261). 유지 |
| M7 | `agent get` (L569) | 응답에 `workspace_id`가 이미 포함됨(L594). 유지 |
목의 `_match_agent`(L182)는 이미 엄격(완전 일치 / sanitize 일치)하므로 변경 불필요하다.
---
## 4. 테스트 계획
### 4.1 `tests/test_herdr_shim_contract.py` — 행위 테스트 (신규 H-15 ~ H-20)
기존 파일의 규약을 따른다: `mam_sandbox` / `mock_herdr` / `mock_agents` 픽스처로 셈을 실제 실행하고, `mock_herdr_state.json``calls` 배열을 검증한다.
**H-15 `test_h15_paste_buffer_inserts_without_enter`** (ISSUE-1)
- 준비: `mock_agents``test-creator-claude` 기동.
- 실행: `herdr set-buffer -b t1 "hello world"``herdr paste-buffer -b t1 -t test-creator-claude`.
- 단언:
- `calls``["pane","send-text",<pane_id>,"hello world"]`가 정확히 1회.
- `calls``["agent","send",...]`**0회** (M1로 이제 실패하게 되므로 회귀 감지).
- `paste-buffer` 실행으로 발생한 `calls``pane send-keys` / `agent prompt` / `pane run`**0회** ← 이중 제출 방지의 핵심 단언.
**H-16 `test_h16_send_keys_safe_submits_exactly_once`** (ISSUE-1 종단)
- `agent prompt` 고속 경로를 강제로 실패시켜(존재하지 않는 세션명 또는 목의 `prompt` 실패 주입) 폴백 경로를 타게 한다.
- 단언: `Enter`/`C-m` 키 전송 횟수 총합이 정확히 1. (현재 코드는 `paste-buffer` 자체가 죽어 0회, 버그 리포트가 기술한 패치 상태에서는 2회 — 양쪽 모두 이 테스트가 잡는다.)
**H-17 `test_h17_no_substring_cross_pane_routing`** (ISSUE-2) — **핵심 회귀 테스트**
- 준비: `reviewer-creator-grok-01`, `worker-grok-02` 두 agent를 서로 다른 pane_id로 기동.
- 실행: `herdr send-keys -t reviewer-creator-grok-01 C-m`.
- 단언: `pane send-keys`의 대상 pane_id가 `reviewer-creator-grok-01`의 것과 일치. `worker-grok-02`의 pane_id로 간 호출은 0회.
- 추가: agent 등록 없이 `agent: "grok"` 라벨 전용 패인만 두고 `herdr has-session -t reviewer-creator-grok-01` → **exit 1**이어야 한다(예전 부분 매칭이면 0).
**H-18 `test_h18_workspace_scoped_pane_resolution`** (ISSUE-3)
- 준비: `state["panes"]`에 동일 `label: creator-agy-01``workspace_id: w1`, `w2`에 각각 1개씩 시드.
- 실행 A: `HERDR_WORKSPACE_ID=w2 herdr send-keys -t creator-agy-01 Enter` → 대상이 `w2`의 pane_id.
- 실행 B: `HERDR_WORKSPACE_ID=w1` → 대상이 `w1`의 pane_id.
- 실행 C: `HERDR_WORKSPACE_ID` 미설정 → 해석은 성공하되 실패하지 않음(기존 전역 동작 보존).
**H-19 `test_h19_single_resolver_helper_used_by_all_branches`** (ISSUE-5)
- 생성된 셈 파일(`$WORKSPACE_ROOT/.mam/shim/herdr`)을 읽어:
- `_resolve_herdr_pane_id()` 정의가 정확히 1회 등장.
- `has-session` / `kill-session` / `capture-pane` / `send-keys` / `paste-buffer` 각 분기 본문에서 `_resolve_herdr_pane_id` 호출이 등장.
- `result', {}).get('agent', {}).get('pane_id'` 형태의 인라인 파서 잔존 개수가 헬퍼 내부 1곳으로 한정.
- `bash -n`으로 셈 구문 검증.
**H-20 `test_h20_pane_id_regex_accepts_alphanumeric_workspace`** (§1.2 회귀 방지)
- 헬퍼를 직접 호출해 `w1E:p1`, `w10:p3` 형태가 통과하고 `notapane`, `w1:p`, 빈 문자열이 거부되는지 확인.
- 이 테스트가 없으면 버그 리포트 원문의 `^w[0-9]+:p[0-9]+$`가 나중에 다시 들어와도 아무도 모른다.
### 4.2 `tests/test_b19_headless_reconcile_fixes.py` — 소스/헬퍼 단위 테스트 (신규 D-4 ~ D-7)
기존 `_run_lib_helpers()` 헬퍼(L119–130)와 소스 문자열 검사 패턴을 재사용한다.
**D-4 `test_resolve_pane_id_fails_cleanly_under_set_e`**
- `set -euo pipefail` 아래에서 `_resolve_herdr_pane_id nonexistent || true`가 셸을 죽이지 않고 빈 출력 + rc 1을 내는지.
**D-5 `test_send_keys_safe_returns_3_when_paste_buffer_fails`** (ISSUE-1)
- `_sks_herdr` 스텁: `agent prompt` → rc 1, `paste-buffer` → rc 1, `send-keys` 호출은 파일에 기록.
- 단언: `send_keys_safe` rc == 3, 기록 파일에 `C-m` 없음.
- 추가 단언: `delete-buffer`가 호출되었음(버퍼 누수 방지).
**D-6 `test_no_substring_matching_remains_in_lib_sh`** (ISSUE-2) — 소스 가드
- `lib.sh` 전문에서 정규식 `\bin tn\b``agent"\) in tn` 패턴 매치가 0건.
- `_resolve_herdr_pane_id` 본문에 `^w[A-Za-z0-9]+:p[A-Za-z0-9]+$`가 존재.
**D-7 `test_paste_buffer_branch_never_submits`** (ISSUE-1) — 소스 가드
- `lib.sh`에서 `paste-buffer)` ~ 다음 `;;` 구간을 잘라내어 `Enter`, `C-m`, `pane run`, `agent prompt` 문자열이 없음을 단언.
- 행위 테스트(H-15)와 중복처럼 보이지만 층이 다르다: H-15는 목 경유라 목이 잘못되면 함께 침묵하고, D-7은 소스를 직접 본다.
### 4.3 회귀 범위
`has-session` 의미 변경(3.3)과 `capture-pane` 경로 변경(3.5)이 넓게 파급되므로, 다음을 우선 확인한 뒤 전체를 돌린다.
```bash
.venv/bin/python -m pytest tests/test_herdr_shim_contract.py \
tests/test_b19_headless_reconcile_fixes.py \
tests/test_b8_send_keys_verification.py \
tests/test_orc_onboard.py tests/test_workspace_scope.py \
tests/test_uuid_target.py tests/test_sanitize_and_mock_errors.py -q
```
이후 전체:
```bash
.venv/bin/python -m pytest -q
```
**기준선 (실측)**: 작업 착수 시점(`4bbd03b`)에 `test_herdr_shim_contract.py` + `test_b19_headless_reconcile_fixes.py` + `test_workspace_scope.py` = **17 passed / 9.2s**.
전체 스위트(`pytest -q`) = **397 passed / 502.00s (8분 21초)**. `tier3`/`tier4` e2e가 herdr 목 프로세스를 다수 포크하는 것이 소요 시간의 대부분이다. 구현자는 다음을 전제로 시간을 배분할 것:
- 반복 개발 루프에서는 4.3의 **우선 범위**만 사용한다(약 10초).
- 전체 회귀는 S7에서 1회만, 백그라운드로 돌린다(약 8~9분).
- 완료 기준은 **397 + 신규 10건 = 407 passed**이다. 이보다 적으면 기존 테스트가 사라졌거나 무성 skip된 것이므로 반드시 원인을 규명할 것.
- `pytest-timeout`은 이 저장소에 설치되어 있지 않다 — `--timeout=` 플래그는 `unrecognized arguments`로 즉시 실패한다. 필요하면 `requirements-dev.txt`에 추가하거나 셸 레벨에서 제어할 것.
---
## 5. 실행 순서 (권장 커밋 단위)
| 단계 | 내용 | 검증 |
|---|---|---|
| S1 | [C10] `conftest.py` 목 정합화 (M1~M5) | 기존 스위트 실행 → **여기서 깨지는 테스트가 곧 은폐되어 있던 결함의 목록**. 목록을 기록한다 |
| S2 | [C1] `_resolve_herdr_pane_id` / `_herdr_ws_scope` 신설 (호출부 변경 없음) | `bash -n`, H-20, D-4 |
| S3 | [C2][C3] 부분 매칭 제거 (ISSUE-2) | H-17, D-6 |
| S4 | [C5][C6][C7] 분기 리팩터링 (ISSUE-5) | H-19, 4.3 우선 범위 |
| S5 | [C8][C9] `paste-buffer` 재작성 + 실패 전파 (ISSUE-1) | H-15, H-16, D-5, D-7 |
| S6 | [C4] `HERDR_WORKSPACE_ID` 전파 (ISSUE-3) | H-18 |
| S7 | 전체 회귀 | `pytest -q` 전량 그린 |
S2를 S3~S6보다 먼저 두는 이유: 헬퍼만 추가하고 아무도 호출하지 않는 상태는 **정의상 무해**하므로, 이 시점에 스위트가 깨지면 원인이 히어독 구문 오류 하나로 좁혀진다.
---
## 6. 리스크 및 완화
| # | 리스크 | 영향 | 완화 |
|---|---|---|---|
| R1 | 셈은 `lib.sh` 내부 히어독이라 편집 시 `$`, 백틱, 따옴표 이스케이프 사고가 나기 쉽다 | 셈 전체가 구문 오류로 죽어 모든 herdr 호출 실패 | `<<'EOF'`(따옴표 히어독)이므로 셸 확장은 일어나지 않음. 각 단계마다 `_init_herdr_isolation` 실행 후 생성물에 `bash -n` |
| R2 | `has-session`이 라벨 전용 패인을 "존재"로 판정 (3.3) | `create_session.sh`가 죽은 패인을 재사용해 세션 재생성 실패 | 4.3 우선 회귀 범위에 `test_orc_onboard.py` 포함. 문제 시 라벨 폴백을 `MAM_HAS_SESSION_PANE_FALLBACK=1` 옵트인으로 격하 |
| R3 | `capture-pane``agent read``pane read`로 전환 | 출력 포맷 차이로 `_pane_quiescent` / 준비 토큰 매칭 실패 | §1.5에서 실기 검증 완료(양쪽 평문). `test_b8_send_keys_verification.py`로 회귀 확인 |
| R4 | `HERDR_WORKSPACE_ID` 하드 필터가 정당한 교차 워크스페이스 조회를 차단 | 다중 워크스페이스 오케스트레이션 기능 상실 | 자동 추론을 채택하지 않음(3.4). 미설정 = 기존 전역 동작. H-18 실행 C가 이를 고정 |
| R5 | 구버전 herdr가 `pane list --workspace`를 모름 | 플래그 오류로 조회 실패 | 클라이언트측 `workspace_id` 필터를 이중으로 유지(3.1). `2>/dev/null || echo ""`로 폴백 |
| R6 | `paste-buffer` 실패 전파(3.7)로 이전엔 "성공"이던 경로가 rc 3을 반환 | 상위 오케스트레이터가 새로 실패를 보게 됨 | 이는 **의도된 결과**다 — 기존 "성공"은 텍스트가 전달되지 않은 무성 실패였다. 다만 배포 노트에 명시 |
---
## 7. 완료 기준 (Definition of Done)
1. `.agents/skills/lib.sh``_resolve_herdr_pane_id`**정확히 1회** 정의되고, `has-session` / `kill-session` / `capture-pane` / `send-keys` / `paste-buffer` 5개 분기가 모두 이를 호출한다.
2. `lib.sh` 전문에 `agent ... in tn` 형태의 부분 문자열 매칭이 0건이다.
3. `paste-buffer` 분기가 `pane send-text`만 사용하고 `Enter` / `C-m` / `pane run` / `agent prompt`를 사용하지 않는다.
4. `HERDR_WORKSPACE_ID`가 설정되면 패인 해석이 해당 워크스페이스로 제한되고, 미설정 시 기존 전역 동작이 보존된다.
5. `tests/test_herdr_shim_contract.py`에 H-15 ~ H-20, `tests/test_b19_headless_reconcile_fixes.py`에 D-4 ~ D-7이 추가되고 전부 통과한다.
6. `.venv/bin/python -m pytest -q`**407 passed**(기준선 397 + 신규 10)로 전량 그린. 실패가 남으면 원인과 함께 명시 보고(무성 skip 금지).
7. `bash -n``lib.sh` 및 생성된 `.mam/shim/herdr` 양쪽에서 통과한다.
---
## 8. 계획 범위 밖으로 남기는 항목
- **ISSUE-4** — 커밋 `4bbd03b`에서 이미 수정 완료. `test_agent_start_success_tokens_exclude_startup_timeout`이 회귀를 고정하고 있어 추가 작업 없음.
- **`list-panes` 분기** — 인라인 파서를 유지한다. `pane_id` 단독이 아니라 `cwd`/`agent`를 함께 파싱하므로 `_resolve_herdr_pane_id`로 대체 불가이며, ISSUE-5의 대상 목록에도 없다.
- **`bug_report.md`의 업스트림 반영** — 본 작업은 이 저장소의 `lib.sh`에 한정한다. `multi-agent-mux` 업스트림 배포는 별도 릴리스 절차(`VERSIONS.md`, `deploy/`)를 따른다.