# 📐 구현 계획서 **Rev.2** — Job `5801cbe2` (원안: `55a872a8`)

- **역할**: Planner (`MULTI_AGENT_RULES.md` §1 — 저장소 코드/문서 무수정, 산출물은 본 보고서)
- **기준 커밋**: `320f036` (working tree clean)
- **베이스라인**: `pytest tests/ --collect-only` → **346 collected**
- **입력**: Job `01d929b8` 리뷰 `[VERDICT: PASS WITH CHALLENGE]` (Challenge C-1, Observation C-2·C-3)

---

## 0. Rev.1 → Rev.2 변경 요약

| 항목 | 판정 | 조치 |
|---|---|---|
| **Challenge C-1** — `resolve_herdr_workspace()` 폴백 우선순위 역전 | **수용. 실측으로 확인, 지적보다 결함이 한 단계 더 확정적** | §4.3 순서 교체 (§1.9) |
| **Observation C-2** — 입양 행에 `herdr_workspace` 누락 | **수용.** 같은 dict 의 `herdr_server` 누락(K-2)까지 함께 닫음 | 신설 **S10** (§1.11) |
| **Observation C-3** — `HERDR_WORKSPACE` 환경변수 비대칭 | **수용.** `set -u` 하 자기참조 확장이 안전함을 실측 | §4.4 (§1.12) |
| **(자체 재감사) 신규** | Rev.1 의 공백 | 재정의된 `resolve_herdr_workspace` 의 **호출자 집합이 Rev.1 에 없었음**. C-1 을 반영하면 **create 는 이 함수를 써서는 안 됨**이 드러남 (§1.10, §3 D5) |

C-1 은 정확합니다. 그리고 챌린저가 제시한 것보다 **한 단계 더 확정적인 결함**입니다 — 챌린저는 *"호출자가 대부분 `ws` 를 넘긴다"* 고 썼는데, 실측하면 `stop_session.sh` 에는 **`--workspace` 파서 자체가 없어서** `${WORKSPACE:-$WORKSPACE_ROOT}` 가 **구조적으로 항상** 호출자의 루트로 고정됩니다(§1.9.1). "다를 수도 있다"가 아니라 "세션의 cwd 가 될 수 없다"입니다.

다만 C-1 을 반영하면 Rev.1 이 덮지 않은 문제가 새로 드러납니다. **행을 먼저 보는 해석기를 `create_session.sh` 가 쓰면 재생성 시 낡은 라벨을 물려받습니다** — create 는 `terminated`/`archived` 동명 행 위에 재생성할 수 있기 때문입니다(§1.10 실측). Rev.2 는 이 함정을 §3 D5 로 명시적으로 닫습니다.

---

## 1. 실측 (Measurements)

> §1.1 ~ §1.8 은 Rev.1 에서 확정된 실측이며 재검증 없이 유지합니다. §1.9 ~ §1.12 가 Rev.2 신규입니다.

### 1.1 `herdr_workspace` — 읽기 6곳, 쓰기 0곳

| # | 위치 | 용도 | 오염 시 결과 |
|---|---|---|---|
| 1 | `lib.sh:1027` `resolve_herdr_session()` | 소켓 이름 해석 | **모든 하위 소비자로 전파** |
| 2 | `reconcile.sh:135` `_srv` | `herdr -L <_srv> kill-session` | 🔴 **파괴적** — 잘못된 소켓에 kill |
| 3 | `reconcile.sh:389` `unique_servers` | 살아있는 세션 열거 | 🔴 세션을 못 찾음 → `terminated` 오판 |
| 4 | `reconcile.sh:486` drift 판정 | `(name, srv) not in alive_set` | 🔴 라이브 세션을 `terminated` 로 덮어씀 |
| 5 | `status.sh:132` | JSON 출력 | 🟡 표시 오류 |
| 6 | `status.sh:241` | 테이블 출력 | 🟡 표시 오류 |

```
'herdr_session':   create_session.sh:314, update_yaml_resumed.sh:121/135, reconcile.sh:566
'herdr_server':    create_session.sh:315, update_yaml_resumed.sh:122/136
'herdr_workspace': (0건)
```

라이브 레지스트리 3개 행 모두 `herdr_workspace=None`.

### 1.2 오인 재현

```
resolve_herdr_session (소켓 이름을 돌려줘야 함)
  legacy(herdr_workspace만 있음)  -> my-workspace-label      ← 라벨이 소켓 이름으로
  both(herdr_session+workspace)   -> real-socket

resolve_herdr_workspace (별칭 — 동일한가?)
  legacy                          -> my-workspace-label
  both                            -> real-socket             ← 라벨을 물었는데 소켓이 나옴
```

### 1.3 `resolve_herdr_workspace()` 는 순수 별칭이고 호출자 4곳 전부 소켓을 원한다

| 호출자 | 대입 대상 | 원하는 것 |
|---|---|---|
| `create_session.sh:217` | `HERDR_SESSION_NAME` | 소켓 |
| `stop_session.sh:107` | `HERDR_SESSION_NAME` | 소켓 |
| `multi-agent-mux-delegate-job:466` | `HERDR_SESSION_NAME` | 소켓 |
| `multi-agent-mux-resume/SKILL.md:76` (문서) | `HERDR_SESSION_NAME` | 소켓 |

### 1.4 `status.sh` 는 이미 라벨과 값이 어긋나 있다

```python
:232  print(f"{'NAME':<44} {'WORKSPACE':<12} ...")                  ← 헤더는 WORKSPACE
:241  server = s.get('herdr_session') or s.get('herdr_server') ...  ← 값은 소켓
```

### 1.5 기존 테스트 2건이 이름과 반대로 동작한다

`tests/test_tier1_unit.py:79/85` 는 함수명이 `..._resolve_herdr_session_...` 인데 `resolve_herdr_workspace` 를 호출합니다. 호출만 바꾸면 이름과 내용이 처음으로 일치합니다.

### 1.6 목표 ① 행동 중립성

6개 지점에서 폴백 항 제거 → **346건 중 추가 실패 0건**. (`test_d23`/`test_d29` 2건 실패는 무뮤테이션 대조군에서도 동일 — `.git`·`nats-docker` 누락 사본 아티팩트.)

동시에 **커버리지 공백**의 증거이기도 합니다: 폴백을 타는 테스트가 0건.

### 1.7 `--herdr-workspace` 기본값의 판별 가능성

`derive_workspace_slug(<repo>)` → `mam-canary-projects-multi-agent-mux`. `herdr_session` 기본값과 **글자 그대로 동일**해질 위험 → §3 D3.

### 1.8 (Rev.1 §1.1 부수) `reconcile.sh:566` 입양 행은 `herdr_server` 를 쓰지 않는다

---

### 1.9 **[Rev.2] Challenge C-1 검증**

#### 1.9.1 전제 확인 — `stop_session.sh` 에는 `--workspace` 파서가 **없다**

```
$ grep -n -- "--workspace\|^WORKSPACE=\|WORKSPACE:-" stop_session.sh
107:  HERDR_SESSION_NAME="$(resolve_herdr_workspace "$SESSION_NAME" "${WORKSPACE:-$WORKSPACE_ROOT}")"
```

`--workspace` case arm 도, `WORKSPACE=` 대입도 없습니다. 즉 `$WORKSPACE` 는 **항상 미설정**이고 `${WORKSPACE:-$WORKSPACE_ROOT}` 는 **항상 `$WORKSPACE_ROOT`** — 운영자가 서 있는 디렉터리입니다. 세션의 실제 cwd 는 `TARGET_CWD` 로 `:113-130` 에서 따로 뽑습니다.

챌린저는 *"대부분의 호출자는 `ws` 를 항상 넘긴다"* 고 썼는데, stop 의 경우는 그보다 강합니다 — 넘기는 값이 **세션의 워크스페이스일 수가 없습니다.**

#### 1.9.2 두 순서의 차이 — 실측

```
session                  ws 인자                Rev.1                 챌린지안
------------------------------------------------------------------------------------
registered-with-label    /path/to/project_b     explicit-label        explicit-label
registered-no-label      /path/to/project_b     to-project-b          to-project-a    <-- 차이
registered-no-label      (없음)                 to-project-a          to-project-a
registered-no-cwd        /path/to/project_b     to-project-b          to-project-b
unregistered-session     /path/to/project_b     to-project-b          to-project-b
unregistered-session     (없음)                 (빈값)                (빈값)
```

**차이는 정확히 한 행뿐**입니다 — *등록된 행 + 라벨 없음 + 호출자의 `ws` 가 행의 `pane.cwd` 와 다름*. 이 경우 Rev.1 은 **호출자의 워크스페이스**를, 챌린지안은 **세션 자신의 워크스페이스**를 돌려줍니다.

그리고 데드 코드 주장도 성립합니다: Rev.1 의 3순위(`if row: pane.cwd`)는 `ws` 가 빈 경우에만 도달하는데, 현재 호출자 3곳 전부 값을 넘기므로 **어느 생산 경로에서도 도달 불가**합니다. 새로 쓰는 함수에 도달 불가 분기를 넣는 것은 그 자체로 설계 오류입니다.

#### 1.9.3 왜 챌린지안이 옳은가 — 저장소의 기존 계약과 일치

| 해석기 | 우선순위 | 호출자 인자의 위치 |
|---|---|---|
| `resolve_herdr_session` (`lib.sh:1025-1044`) | 행 → 폴백 | 행이 없을 때만 |
| `agent_of_row` (`registry.py:26`) | `agent` 필드 → 이름 → `pane.cmd` | **없음** (전부 행 유래) |
| **Rev.1 §4.3** | 라벨 → **호출자 `ws`** → `pane.cwd` | 행 유래 사실보다 위 ❌ |

Rev.1 은 자기 §D4 가 세운 원칙("엉뚱한 출처가 새어 들어오면 안 된다")을 자기 구현에서 어겼습니다. **등록된 행이 있으면 행에 적힌 사실이 호출자 인자를 이깁니다.** 챌린지 수용.

### 1.10 **[Rev.2 자체 재감사] C-1 을 반영하면 create 는 이 함수를 쓰면 안 된다**

C-1 을 반영하면 해석기가 **행을 먼저** 봅니다. 그런데 `create_session.sh:296-307` 은 동명 행 위에 **재생성이 가능**합니다:

```python
running_same = [s for s in sessions if s.get('name') == name and s.get('status') == 'running']
if running_same:
    raise SystemExit(4)                       # running 이면 거부
sessions[:] = [s for s in sessions if s.get('name') != name]   # terminated/archived 는 제거 후 재등록
```

따라서 `--session <기존 이름>` 으로 **다른 디렉터리에서** 재생성할 때, 행-우선 해석기를 쓰면 **낡은 `pane.cwd` 에서 파생된 라벨을 물려받습니다**. create 는 새 사실을 *세우는* 쪽이지 *조회하는* 쪽이 아닙니다.

→ **create 의 기본값은 `$WORKSPACE` 에서 직접 계산합니다**(§3 D5). 이것이 안전한 이유는 두 슬러그 구현의 패리티가 성립하기 때문입니다:

```
경로                                            bash derive_workspace_slug(-mam)      python slug()
/Users/.../canary_projects/multi-agent-mux      canary-projects-multi-agent-mux       canary-projects-multi-agent-mux   일치
/tmp                                            workspace-tmp                         workspace-tmp                     일치
/private/var/folders/q_/x                       q--x                                  q--x                              일치
/Users/godopu16/My_Proj.v2                      godopu16-my-projv2                    godopu16-my-projv2                일치
/                                               workspace-root                        workspace-root                    일치
```

5/5 일치(`_`→`-` 치환, `.` 제거, 루트 처리 포함). 다만 **두 구현이 존재한다는 사실 자체가 리스크**이므로 §5 T10 으로 패리티를 계약화합니다.

### 1.11 **[Rev.2] Observation C-2 검증**

`reconcile.sh:560-573` 입양 dict:

```python
entry = {
    'name': name, 'status': 'running', 'role': role,
    'herdr_session_created_at': ..., 'herdr_session_epoch': created_epoch,
    'herdr_session': srv,                                    ← herdr_server 없음 (K-2)
    'pane': {..., 'cwd': pm['cwd']},                         ← cwd 는 여기 이미 있음
    'start_command': f'... -c "{pm["cwd"]}" ...',
    ...
}
```

`herdr_workspace` 도 없고 `herdr_server` 도 없습니다. 그리고 파생에 필요한 `pm['cwd']` 는 **같은 dict 안에 이미 있습니다**. 두 줄 추가로 C-2 와 K-2 를 동시에 닫을 수 있어, Rev.1 이 범위 밖(K-2)으로 뒀던 판단을 뒤집습니다 — 비용이 사실상 0 이고 §4.7 이 이 필드를 표시하기 시작하는 이상 입양 행만 `-` 로 뜨는 것은 새 드리프트입니다.

### 1.12 **[Rev.2] Observation C-3 검증 — `set -u` 안전**

```
 [env 미설정]                                 [env 설정]
  OPT=(없음)   env=(미설정) -> proj-x          OPT=(없음)   env=from-env -> from-env
  OPT=from-flag env=(미설정) -> from-flag       OPT=from-flag env=from-env -> from-flag
```

`set -euo pipefail` 하에서 `${HERDR_WORKSPACE_OPT:-${HERDR_WORKSPACE:-${ws_slug#mam-}}}` 는 **unbound 오류 없이** 플래그 > env > 슬러그 순으로 동작합니다. `HERDR_SESSION_NAME` 과 대칭이 맞습니다. 수용.

---

## 2. 범위

**포함**

| # | 항목 |
|---|---|
| **S1** | 6개 읽기 지점에서 `herdr_workspace` 폴백 항 제거 → `herdr_session or herdr_server` 고정 |
| **S2** | 호출자 4곳 → `resolve_herdr_session` 이관 + `test_tier1_unit.py` 2건 정정 (**게이트**) |
| **S3** | `resolve_herdr_workspace()` 재정의 — **C-1 순서** 적용 |
| **S4** | `create_session.sh`: `--herdr-workspace` 파싱·usage·**env 폴백(C-3)**·기본값·YAML |
| **S5** | `resume_session.sh` / `update_yaml_resumed.sh`: `--herdr-workspace` 지원·영속화 |
| **S6** | `stop_session.sh`: `--herdr-workspace` usage/parser |
| **S7** | `status.sh` 컬럼 분리, `reconcile.sh` 라벨 표시 |
| **S8** | SKILL.md 3종 + `resume/SKILL.md:76` |
| **S9** | 테스트 tier1 + tier2 신설 |
| **S10** | **[Rev.2 신설]** `reconcile.sh:566` 입양 행에 `herdr_workspace` + `herdr_server` 기입 (C-2 + K-2) |

**제외**

| 항목 | 사유 |
|---|---|
| `multi-agent-mux-delegate-job` 소켓 lookup 재설계 | `:466` 한 줄이 전부이고 S2 로 해소 (§1.3 전수 확인) |
| `reconcile.sh` 의 `herdr -L <srv>` vs 심의 `--session` 불일치 | 선재 이슈, 브리프와 무관 → K-3 |
| `herdr_server` 필드 **제거** | 하위 호환 별칭으로 유지 (S10 은 *추가*이지 제거가 아님) |

---

## 3. 설계 결정

### D1 — 순서: ①이 ②보다 반드시 먼저 (Rev.1 유지)

`herdr_workspace` writer 가 0 이라 결함이 잠복 상태이고, 목표 ②가 바로 그 writer 를 만듭니다. S1 없이 S4 만 넣으면 그 커밋이 결함을 활성화합니다. S1 은 §1.6 대로 오늘 무해합니다.

### D2 — 이름 되찾기: 호출자 이관 → 재정의 2단계 (Rev.1 유지)

1단계 후 `grep -rn 'resolve_herdr_workspace' --include='*.sh' --include='*.py' .` 이 **정의 1줄 외 0건**임을 게이트로 확인하고 2단계 진입.

### D3 — `--herdr-workspace` 기본값: `mam-` 접두사 없는 슬러그 (Rev.1 유지)

접두사를 유지하면 두 필드가 기본 상태에서 동일 문자열이 되어 **테스트가 두 필드를 구분하지 못합니다**(J-2 의 `n=3` 함정과 동형). `derive_session_name()` 이 이미 쓰는 `${base_slug#mam-}` 관용구를 재사용합니다.

### D4 — 폴백 체인의 최종 형태 (Rev.1 유지)

```python
srv = s.get('herdr_session') or s.get('herdr_server') or 'default'   # 라벨은 절대 들어오지 않음
ws  = s.get('herdr_workspace') or <pane.cwd 파생>                     # 소켓으로 폴백하지 않음
```

### D5 — **[Rev.2 신설]** 재정의된 해석기의 **호출자 집합**

Rev.1 은 함수를 재정의하면서 **누가 부를지 적지 않았습니다.** C-1 을 반영하면 이 공백이 실제 함정이 됩니다(§1.10).

| 소비자 | 해석 방법 | 이유 |
|---|---|---|
| `update_yaml_resumed.sh` | **`resolve_herdr_workspace` 호출** | 등록된 행의 사실이 우선이어야 함 — C-1 이 겨냥한 정확한 경우 |
| `create_session.sh` | **`${ws_slug#mam-}` 직접 계산** (함수 미사용) | 재생성 시 낡은 행의 `pane.cwd` 를 물려받지 않기 위해 (§1.10 실측) |
| `status.sh` / `reconcile.sh` | 행의 `herdr_workspace` 를 읽고, 없으면 `pane.cwd` 에서 인라인 파생 | 표시 전용, 인라인 Python 이라 `lib.sh` 를 거치지 않음 |
| `stop_session.sh` | 사용하지 않음 | 소켓만 필요 (§4.6) |

**create 가 함수를 쓰지 않는다는 결정이 D5 의 핵심**입니다. 두 슬러그 구현이 갈릴 위험은 §5 T10 패리티 테스트로 막습니다.

### D6 — **[Rev.2 신설]** `--workspace` 는 라벨링 수단이 아니다

C-1 의 이면입니다. 운영자가 라벨을 바꾸고 싶으면 `--herdr-workspace` 를 씁니다. `--workspace` 는 "이 명령이 실행되는 맥락"이지 "세션이 속한 워크스페이스"가 아닙니다. 이 구분을 §4.6 usage 와 SKILL.md 에 한 줄씩 명시합니다.

---

## 4. 구현

### 4.1 S1 — 폴백 항 제거 (6곳)

```diff
- val = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace')
+ val = s.get('herdr_session') or s.get('herdr_server')
```
`lib.sh:1027`. 동형으로 `reconcile.sh:135/389/486`, `status.sh:132/241` (뒤 넷은 `... or 'default'` 유지).

각 지점 주석:

```python
# herdr_workspace 는 워크스페이스 *라벨* 이지 소켓 이름이 아니다. 폴백에 넣으면
# 라벨이 `herdr -L <name>` 의 소켓 인자로 흘러들어간다 (reconcile.sh:135 는 kill).
```

### 4.2 S2 — 호출자 이관 (게이트)

| 파일:줄 | 변경 |
|---|---|
| `create_session.sh:217`, `stop_session.sh:107`, `multi-agent-mux-delegate-job:466` | `resolve_herdr_workspace` → `resolve_herdr_session` |
| `multi-agent-mux-resume/SKILL.md:76`, `multi-agent-mux-delegate-job:43`(주석), `lib.sh:1011`(주석) | 〃 |
| `tests/test_tier1_unit.py:82, :88, :92` | 〃 (§1.5) |

### 4.3 S3 — `resolve_herdr_workspace()` 재정의 (**C-1 반영**)

```bash
# resolve_herdr_workspace <session_name> [workspace]
#
# 이 MAM 세션 행의 워크스페이스 *라벨* 을 돌려준다. herdr 소켓/데몬 이름이
# 아니다 — 그쪽은 resolve_herdr_session() 이다. 라벨이 소켓 인자로 흘러가면
# reconcile.sh 가 엉뚱한 소켓에 kill-session 을 날린다.
#
# 우선순위 (C-1: 등록된 행의 사실이 호출자 인자를 이긴다):
#   ① row['herdr_workspace']            — 명시 기록
#   ② row['pane']['cwd'] 의 슬러그       — 등록된 세션의 실제 작업 디렉터리
#   ③ 인자 workspace 의 슬러그           — 미등록 세션 전용 폴백
#   ④ 빈 문자열
# 주의 1: herdr_session / herdr_server 로는 절대 폴백하지 않는다 (D4).
# 주의 2: create_session.sh 는 이 함수를 쓰지 않는다 — 재생성 시 낡은 행의
#         pane.cwd 를 물려받기 때문 (D5).
resolve_herdr_workspace() {
  local session_name="$1"
  local workspace="${2:-}"
  MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" TARGET_WS="$workspace" python3 -c "
import sys, os, json, re
name = os.environ['SESSION_NAME']
ws   = os.environ.get('TARGET_WS', '').strip()
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))

def slug(path):
    if not path:
        return ''
    a = os.path.abspath(path)
    parent = os.path.basename(os.path.dirname(a)) or 'workspace'
    work   = os.path.basename(a) or 'root'
    if parent in ('/', '.'): parent = 'workspace'
    if work   in ('/', '.'): work   = 'root'
    s = f'{parent}-{work}'.lower().replace('_', '-')
    return re.sub(r'[^a-zA-Z0-9-]', '', s).lstrip('-')

row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), None)

# ① 명시 기록
if row and row.get('herdr_workspace'):
    print(row['herdr_workspace']); sys.exit(0)

# ② 등록된 행의 실제 cwd — 호출자 인자보다 우선 (C-1)
if row:
    derived = slug((row.get('pane') or {}).get('cwd', ''))
    if derived:
        print(derived); sys.exit(0)

# ③ 미등록(또는 cwd 부재) 세션 폴백
if ws:
    derived = slug(ws)
    if derived:
        print(derived); sys.exit(0)

print('')
"
}
```

Rev.1 대비 바뀐 것은 ②와 ③의 순서, 그리고 ②가 빈 값을 낼 때 ③으로 흘러가도록 `if derived:` 가드를 둔 점입니다(챌린저 처방 그대로).

### 4.4 S4 — `create_session.sh` (**C-3 + D5 반영**)

```bash
HERDR_WORKSPACE_OPT=""                                        # :56 부근, set -u 안전
...
--herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;;       # :68 부근
```

usage:

```
  --herdr-workspace NAME  workspace label recorded in the registry
                          (flag > $HERDR_WORKSPACE > workspace slug without mam-).
                          A label only — it never selects a herdr socket;
                          use --herdr-session for that.
```

기본값 — `ws_slug` 계산 직후 **한 곳에서만** 계산합니다:

```bash
# 플래그 > 환경변수 > 워크스페이스 슬러그 (C-3: HERDR_SESSION_NAME 과 대칭).
# D5: resolve_herdr_workspace 를 쓰지 않는다 — 동명 terminated 행 위에 재생성할 때
#     낡은 pane.cwd 에서 파생된 라벨을 물려받기 때문 (create 는 사실을 세우는 쪽).
MAM_WS_LABEL="${HERDR_WORKSPACE_OPT:-${HERDR_WORKSPACE:-${ws_slug#mam-}}}"
```

> 내부 변수를 `HERDR_WORKSPACE` 가 아니라 `MAM_WS_LABEL` 로 둡니다. 같은 이름을 쓰면 이후 `atomic_dump_yaml ... HERDR_WORKSPACE="$HERDR_WORKSPACE"` 에서 **입력 채널과 출력 채널이 한 이름을 공유**해 읽는 사람이 어느 쪽인지 판단할 수 없게 됩니다. `create_session.sh` 는 `HERDR_SESSION_NAME` 블록을 `:140` 과 `spawn():176` 두 곳에 중복시킨 전력이 있으므로, 이 계산은 **단일 지점**임을 주석으로 못박습니다.

dry-run 출력에 실어 파싱 감도를 확보합니다(`1b18eb9a` §4.1 교훈):

```bash
echo "[dry-run] would spawn: herdr session '$SESSION_NAME' in $WORKSPACE (agent=$AGENT, herdr_session=${HERDR_SESSION_NAME:-default}, herdr_workspace=${MAM_WS_LABEL})"
```

YAML 직렬화 (`:314-315` 옆, env 는 `MAM_WS_LABEL="$MAM_WS_LABEL"` 로 전달):

```python
    'herdr_session': server_name,
    'herdr_server': server_name,
    'herdr_workspace': os.environ.get('MAM_WS_LABEL', ''),
```

### 4.5 S5 — resume 계열

`resume_session.sh` / `update_yaml_resumed.sh` 에 `--herdr-workspace` 파싱을 추가하고, `resume_session.sh` 는 **두 호출 지점 모두**(`:72-74`, `:136-138`)에 전달합니다. `2d3fef82` 에서 `--herdr-session` 이 정확히 이 대칭 누락으로 반려됐습니다.

`update_yaml_resumed.sh` 는 **D5 대로 `resolve_herdr_workspace` 를 사용**합니다:

```bash
if [ -n "$HERDR_WORKSPACE_OPT" ]; then
  MAM_WS_LABEL="$HERDR_WORKSPACE_OPT"
  export MAM_WS_LABEL_EXPLICIT="1"
else
  MAM_WS_LABEL="$(resolve_herdr_workspace "$SESSION_NAME" "${WORKSPACE:-}")"
  export MAM_WS_LABEL_EXPLICIT="0"
fi
export MAM_WS_LABEL
```

영속화는 `--herdr-session` 이 확립한 명시/백필 패턴을 그대로 따릅니다:

```python
else:
    wsl = os.environ.get('MAM_WS_LABEL', '')
    ws_explicit = os.environ.get('MAM_WS_LABEL_EXPLICIT') == '1'
    if wsl and (ws_explicit or not target.get('herdr_workspace')):
        target['herdr_workspace'] = wsl
```

신규 행(`target is None`) 분기에도 `'herdr_workspace': wsl` 을 추가합니다 — `1b18eb9a` §O-1 이 지적한 커버리지 공백을 §5 T7 로 함께 닫습니다.

### 4.6 S6 — `stop_session.sh`

usage/parser 에 추가하되 라우팅에는 쓰지 않습니다(D6):

```
  --herdr-workspace <name> — recorded label only; never selects a socket
                             (use --herdr-session for that). Note: stop has no
                             --workspace flag — the session's own workspace is
                             read from its registry row, not from where you stand.
```

### 4.7 S7 — 표시

```python
print(f"{'NAME':<44} {'SOCKET':<12} {'WORKSPACE':<14} {'YAML':<10} {'HERDR':<6} ...")
...
socket  = s.get('herdr_session') or s.get('herdr_server') or 'default'
wslabel = s.get('herdr_workspace') or _slug((s.get('pane') or {}).get('cwd','')) or '-'
```

§1.4 의 라벨/값 불일치가 여기서 해소됩니다. `status.sh:132` JSON 에도 `herdr_workspace` 키 추가(기존 `server` 키는 계약이므로 유지).

### 4.8 S10 — **[Rev.2 신설]** 입양 행 (C-2 + K-2)

`reconcile.sh:566` 부근, 같은 dict 안에 이미 있는 `pm['cwd']` 를 재사용:

```python
    'herdr_session': srv,
    'herdr_server': srv,                       # K-2: 다른 두 writer 와 필드 세트 정합
    'herdr_workspace': _slug(pm['cwd']),       # C-2: 입양 행만 WORKSPACE 가 '-' 로 뜨지 않도록
```

`_slug()` 는 `reconcile.sh` 인라인 Python 안의 헬퍼로 두되, §5 T10 이 `lib.sh` 구현과의 패리티를 계약화합니다.

---

## 5. 테스트 계획

신설 **13건** (Rev.1 8건 + Rev.2 5건). 예상 collected **346 → 359**.

### T1 (tier1) — 두 해석기가 다른 것을 돌려준다

```python
seed_row(name="d-creator-claude", herdr_session="socket-A", herdr_workspace="label-B")
assert resolve_herdr_session(...)   == "socket-A"
assert resolve_herdr_workspace(...) == "label-B"
```

### T2 (tier1) — 라벨이 소켓으로 새지 않는다 (**핵심 가드**)

```python
seed_row(name="legacy-creator-claude", herdr_workspace="my-label")   # herdr_session 없음
assert resolve_herdr_session("legacy-creator-claude") != "my-label"
```

§1.6 대로 현재 스위트에 이 성질을 잡는 테스트가 0건입니다. 제거 확인이 아니라 **재도입 검출**이 목적입니다.

### T3 (tier1) — 소켓 해석기 폴백 항이 정확히 둘

`herdr_server` 만 있는 행 → 그 값. 둘 다 없는 행 → 기존 계약 유지.

### T3b (tier1) — **[Rev.2 신설]** C-1 우선순위 계약

```python
def test_workspace_resolver_prefers_the_row_over_the_caller_argument(mam_sandbox):
    """C-1: 등록된 행에는 herdr_workspace 가 없지만 pane.cwd 가 있다.
    호출자가 '다른' 워크스페이스를 넘겨도 행의 cwd 가 이긴다.
    (stop_session.sh 는 --workspace 파서가 없어 항상 호출자의 루트를 넘긴다.)"""
    seed_row(name="pa-creator-claude", pane_cwd="/path/to/project_a")   # 라벨 없음
    r = run_lib_func(mam_sandbox, "resolve_herdr_workspace",
                     "pa-creator-claude", "/path/to/project_b")
    assert r.stdout.strip() == "to-project-a"       # ← project_b 가 아님

def test_workspace_resolver_uses_the_argument_only_when_unregistered(mam_sandbox):
    """③ 분기가 살아 있음을 확인 — 미등록 세션에서는 인자가 쓰인다."""
    r = run_lib_func(mam_sandbox, "resolve_herdr_workspace",
                     "not-registered", "/path/to/project_b")
    assert r.stdout.strip() == "to-project-b"
```

두 번째 단언이 중요합니다 — C-1 을 반영하면서 ③ 분기를 통째로 죽이지 않았음을 고정합니다.

### T4 (tier2) — `--herdr-workspace` 파싱 + 기본값 + **env 폴백(C-3)**

```python
assert "herdr_workspace=my-label" in dry_run(flag="my-label")
# 생략 + env 설정 → env 가 이긴다 (C-3)
assert "herdr_workspace=from-env" in dry_run(env={"HERDR_WORKSPACE": "from-env"})
# 플래그와 env 동시 → 플래그가 이긴다
assert "herdr_workspace=my-label" in dry_run(flag="my-label", env={"HERDR_WORKSPACE": "from-env"})
# 둘 다 없음 → 접두사 없는 슬러그, 그리고 herdr_session 기본값과 다르다 (D3)
out = dry_run()
assert f"herdr_workspace={bare}" in out and f"herdr_session=mam-{bare}" in out
```

마지막 줄이 **한 테스트 안에서 두 필드가 서로 다름**을 고정합니다.

### T5 (tier2) — create YAML 전파

`herdr_session` / `herdr_server` / `herdr_workspace` 3개를 각각 단언하고, `herdr_workspace` 값이 `start_command`/`attach_command`/`kill_command` 에 **들어가지 않음**을 함께 단언(라벨이 라우팅에 새지 않음).

### T6 (tier2) — resume 전파 (양쪽 호출 지점)

`--herdr-workspace NEW-LABEL` → 행의 `herdr_workspace` 갱신, `herdr_session` **불변**.

### T7 (tier2) — resume 신규 행 분기

`herdr_sessions: []` 로 시작 → `herdr_session`·`herdr_server`·`herdr_workspace` 3개 모두 기록. (`1b18eb9a` §O-1)

### T8 (tier2) — stop 인자 수용

`test_comp_stop_usage_matches_parser` 플래그 목록에 `--herdr-workspace` 추가.

### T9 (tier2) — **[Rev.2 신설]** create 재생성 함정 (D5)

```python
def test_create_does_not_inherit_a_stale_workspace_label(mam_sandbox, mock_herdr, mock_agents):
    """D5: 동명 terminated 행이 다른 cwd 를 갖고 있어도, 재생성은 --workspace 에서
    라벨을 파생한다. (행-우선 해석기를 쓰면 낡은 라벨을 물려받는다.)"""
    seed_row(name="reuse-creator-claude", status="terminated",
             pane_cwd="/old/place", herdr_workspace="old-label")
    run_create(workspace=mam_sandbox, session="reuse-creator-claude")   # --herdr-workspace 없음
    row = read_row("reuse-creator-claude")
    assert row["herdr_workspace"] != "old-label"
    assert row["herdr_workspace"] == expected_bare_slug(mam_sandbox)
```

### T10 (tier1) — **[Rev.2 신설]** 슬러그 구현 패리티

```python
@pytest.mark.parametrize("path", ["/tmp", "/", "/a/My_Proj.v2", "/private/var/folders/q_/x"])
def test_slug_parity_between_bash_and_python(mam_sandbox, path):
    """D5 는 두 슬러그 구현의 일치에 의존한다 (lib.sh derive_workspace_slug 와
    resolve_herdr_workspace / reconcile.sh 의 인라인 slug())."""
    b = run_lib_func(mam_sandbox, "derive_workspace_slug", path).stdout.strip()
    p = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "not-registered", path).stdout.strip()
    assert b.removeprefix("mam-") == p
```

§1.10 에서 5/5 일치를 실측했으므로 이 테스트는 현재 통과합니다. 값어치는 **미래의 분기 방지**입니다.

### T11 (tier2) — **[Rev.2 신설]** 입양 행 (S10)

reconcile drift-B 입양을 태우고 새로 등록된 행에 `herdr_session`·`herdr_server`·`herdr_workspace` 3개가 모두 있고, `herdr_workspace` 가 `pane.cwd` 파생값과 일치함을 단언.

### T12 (tier2) — **[Rev.2 신설]** 표시 컬럼 분리 (S7)

소켓과 라벨이 다른 행을 심고 `status.sh` 출력에서 **두 값이 각자 컬럼에 나타남**을 단언. §1.4 의 헤더/값 불일치 회귀 방지.

---

## 6. 뮤테이션 매트릭스

| # | 뮤테이션 | FAIL 해야 하는 테스트 |
|---|---|---|
| M1 | `lib.sh:1027` 에 `or s.get('herdr_workspace')` 재도입 | **T2** |
| M2 | `resolve_herdr_workspace` 를 다시 별칭으로 | **T1** |
| M3 | 새 해석기에 `or row.get('herdr_session')` 폴백 추가 (D4 위반) | **T1** |
| **M3b** | **[Rev.2]** ②③ 순서를 Rev.1 로 되돌림 (`ws` 를 `pane.cwd` 앞으로) | **T3b 첫 단언** |
| **M3c** | **[Rev.2]** ③ 분기 삭제 (과잉 교정) | **T3b 둘째 단언** |
| M4 | `reconcile.sh:486` 에 폴백 항 재도입 | **미검출** — 아래 정적 가드로 대응 |
| M5 | create 파서가 `--herdr-workspace` 값을 버림 | **T4, T5** |
| M6 | 기본값을 `${ws_slug}` (접두사 유지)로 | **T4** |
| **M6b** | **[Rev.2]** env 폴백 제거 (`${HERDR_WORKSPACE:-}` 항 삭제) | **T4 둘째 단언** |
| M7 | `herdr_workspace` 를 `start_command` 에 주입 | **T5** |
| M8 | resume 주 경로에서 `--herdr-workspace` 미전달 | **T6** |
| M9 | 신규 행 dict 에서 `herdr_workspace` 제거 | **T7** |
| **M10** | **[Rev.2]** create 가 `resolve_herdr_workspace` 를 쓰도록 변경 (D5 위반) | **T9** |
| **M11** | **[Rev.2]** 입양 dict 에서 `herdr_workspace` 제거 | **T11** |
| **M12** | **[Rev.2]** `status.sh` 가 두 컬럼에 같은 값을 출력 | **T12** |

**M3b 와 M3c 가 서로 다른 단언을 깨야 합니다.** 하나는 순서 역전을, 다른 하나는 과잉 교정(`ws` 분기 제거)을 잡습니다. 둘 중 하나라도 잡히지 않으면 T3b 가 한쪽만 보는 테스트라는 뜻입니다 — J-2 에서 `n=3` 을 골라 M6 을 판별하지 못했던 실수를 반복하지 않기 위한 조건입니다.

**M4 를 정직하게 남깁니다.** `reconcile.sh`/`status.sh` 의 4개 지점은 각자 인라인 Python 이라 `lib.sh` 해석기를 거치지 않습니다. T2 는 `lib.sh` 만 지킵니다. 픽스처 4개 대신 **소스 수준 정적 가드 1건**으로 묶습니다.

```python
def test_no_socket_lookup_falls_back_to_workspace_label():
    """B-22 구조 가드: 소켓 lookup 표현식에 herdr_workspace 가 다시 끼어들지 못한다.
    reconcile.sh:135 는 이 값을 `herdr -L <name> kill-session` 에 넘긴다."""
    pat = re.compile(r"herdr_session'\)\s*or\s*.*herdr_workspace")
    for f in (LIB_SH, RECONCILE_SH, STATUS_SH):
        for i, line in enumerate(f.read_text().splitlines(), 1):
            assert not pat.search(line), f"{f.name}:{i} — socket lookup falls back to the workspace label:\n{line}"
```

문자열 가드는 원래 감도가 약하지만, 이 결함은 **형태 자체가 한 줄 관용구**라 정확히 겨냥할 수 있습니다. **M4 를 실제로 검출하는지 뮤테이션으로 확인하는 것**을 수용 조건에 넣습니다.

---

## 7. 커밋 분할

| # | 커밋 | 내용 | 선행 |
|---|---|---|---|
| **1** | `fix(lib,monitor,status): stop resolving the workspace label as a herdr socket name (B-22)` | S1 + T2 + M4 정적 가드 | — |
| **2** | `refactor(lib,skills): point every caller at resolve_herdr_session (B-22)` | S2 (게이트 포함) | 1 |
| **3** | `feat(lib): make resolve_herdr_workspace return the workspace label (B-22)` | S3 + T1 + T3 + **T3b** + **T10** | 2 |
| **4** | `feat(create): add --herdr-workspace and serialize it as a distinct field` | S4 + T4 + T5 + **T9** | 3 |
| **5** | `feat(resume,stop): support --herdr-workspace end to end` | S5 + S6 + T6 + T7 + T8 | 4 |
| **6** | `feat(status,monitor): record and show the workspace label` | S7 + **S10** + **T11** + **T12** | 4 |
| **7** | `docs(skills): document --herdr-workspace and the socket/label split` | S8 | 5, 6 |

커밋 1 이 반드시 첫 번째여야 합니다(D1). 커밋 1~3 은 §1.6 대로 전부 행동 중립이며 실제 기능은 커밋 4 부터 시작합니다. 커밋 2/3 분리는 D2 게이트 때문입니다.

Rev.1 대비 변경: 커밋 3 에 T3b·T10, 커밋 4 에 T9, 커밋 6 에 S10·T11·T12 가 추가됐습니다. 커밋 개수는 그대로입니다.

---

## 8. 검증 절차 (Creator 실행)

```bash
# 1) 구문 — 변경 7개 스크립트 bash -n

# 2) D2 게이트 (커밋 2 직후) — 정의 1줄만 남아야 함
grep -rn 'resolve_herdr_workspace' --include='*.sh' --include='*.py' . | grep -v '^./.agents/reports/'

# 3) 폴백 항 소멸 (커밋 1 직후)
grep -rn "or s.get('herdr_workspace')" --include='*.sh' . | grep -v '^./.agents/reports/'
#   → 0건

# 4) C-1 순서 직접 확인 (커밋 3 직후)
#    herdr_workspace 없고 pane.cwd=/path/to/project_a 인 행에
#    resolve_herdr_workspace <name> /path/to/project_b
#    → to-project-a 여야 함 (to-project-b 면 순서가 역전된 것)

# 5) 전체 스위트 (베이스라인 346 → 기대 359)
.venv/bin/python -m pytest tests/ -q

# 6) 뮤테이션 M1~M12 + M4 정적 가드 확인
```

> **측정 주의**: 격리 사본에서 스위트를 돌릴 때는 `.git` 과 `nats-docker/` 를 함께 복사하십시오. 빠뜨리면 `test_d23_compose_image_matches_doc_and_is_alpine` 와 `test_d29_env_secrets_never_tracked` 가 **사본 아티팩트로** 실패해 뮤테이션 결과를 오독합니다(§1.6 에서 실제로 발생).

---

## 9. 후속 백로그 (범위 밖, 등록만)

| ID | 내용 |
|---|---|
| **K-1** | `test_o2_18_orphan_steal_lock_recovered` 부하 민감 플레이크 — `acquire_bg()` 의 고정 `time.sleep(0.3)` |
| ~~K-2~~ | ~~입양 행 `herdr_server` 누락~~ → **S10 으로 범위 내 흡수** |
| **K-3** | `reconcile.sh:392-396` 이 `herdr -L <srv>` 를 `subprocess.run` 으로 직접 호출 — `lib.sh` 심의 `--session` 경로 우회. 소켓 스코핑이 실제로 걸리는지 미검증 |
| **K-4** | `README.md:98,100` / `README.ko.md:80,82` 의 구 `herdr -L <server>` 서술 (선재 드리프트) |
| **K-5** | `create_session.sh:216` 의 `HERDR_SERVER_OPT` 가드 무동작 (`1b18eb9a` §O-2) |
| **K-6** | **[Rev.2 신설]** `stop_session.sh` 에 `--workspace` 파서 부재 — `${WORKSPACE:-$WORKSPACE_ROOT}` 가 항상 후자로 고정(§1.9.1). D6 대로 stop 은 행에서 읽으면 되므로 이번 범위에서는 결함이 아니지만, `resolve_herdr_session` 의 미등록 폴백 품질에는 영향 |

---

## 10. 규모 추정

| 파일 | 변경 |
|---|---|
| `lib.sh` | +36 / −3 |
| `reconcile.sh` | +9 / −3 (S10 포함) |
| `status.sh` | +10 / −2 |
| `create_session.sh` | +15 |
| `resume_session.sh` | +8 |
| `update_yaml_resumed.sh` | +18 |
| `stop_session.sh` | +6 |
| `multi-agent-mux-delegate-job` | +1 / −1 |
| SKILL.md 3종 + `resume/SKILL.md` | +20 |
| `tests/test_tier1_unit.py` | +60 (T1~T3b, T10, 기존 2건 정정) |
| `tests/test_tier2_component.py` | +140 (T4~T9, T11, T12) |
| 정적 가드 | +12 |

총 **약 +335 / −9 줄**, 파일 12개, 커밋 7개. 규모 **중** (Rev.1 대비 테스트 +87줄).

---

## 11. 챌린저에게

C-1 은 정확하고, 실측해 보니 지적보다 **한 단계 더 확정적**이었습니다. `stop_session.sh` 에는 `--workspace` 파서가 아예 없어서(§1.9.1) 넘어가는 값이 세션의 워크스페이스일 **가능성 자체가 없습니다**. "다를 수 있다"가 아니라 "구조적으로 다르다"입니다. 그리고 Rev.1 의 3순위가 어느 생산 경로에서도 도달 불가라는 데드 코드 지적도 그대로 성립합니다.

무엇보다, Rev.1 은 자기 §D4 가 세운 원칙("엉뚱한 출처가 새어 들어오면 안 된다")을 자기 §4.3 구현에서 어겼습니다. 같은 저장소의 `resolve_herdr_session` 과 `agent_of_row` 는 둘 다 행 유래 사실을 호출자 인자보다 앞에 둡니다. 제 구현만 예외였습니다.

C-1 을 반영하면서 Rev.1 이 덮지 않은 문제가 하나 새로 드러났습니다 — **재정의된 함수를 누가 부를지 Rev.1 에 없었고**, 행-우선 해석기를 `create_session.sh` 가 쓰면 동명 `terminated` 행 위에 재생성할 때 낡은 라벨을 물려받습니다(§1.10). D5 와 T9/M10 으로 닫았습니다. 지적 하나가 계획의 다른 구멍을 드러낸 셈입니다.

C-2 는 수용하면서 Rev.1 이 범위 밖(K-2)으로 뒀던 `herdr_server` 누락도 함께 끌어왔습니다. 같은 dict 두 줄이고, §4.7 이 이 필드를 표시하기 시작하는 이상 입양 행만 `-` 로 뜨는 것은 새 드리프트이기 때문입니다.

C-3 도 수용했습니다. 다만 내부 변수명을 `HERDR_WORKSPACE` 대신 `MAM_WS_LABEL` 로 둡니다 — 같은 이름이면 입력 채널(사용자 env)과 출력 채널(`atomic_dump_yaml` 전달)이 한 이름을 공유해 읽는 사람이 구분할 수 없게 되고, 이 파일은 `HERDR_SESSION_NAME` 블록을 두 곳에 중복시킨 전력이 있습니다.
