# 📐 구현 계획서 Rev.2: 백로그 I-2 / I-3 처리 (Job `5e4ef463`)

- **작성일**: 2026-08-23
- **역할**: Planner (`.agents/MULTI_AGENT_RULES.md` §1 — Planner 는 저장소 코드/문서를 **수정하지 않으며**, 산출물은 본 계획서입니다)
- **기준 커밋**: `31b2d70`, 작업 트리 clean
- **선행 리비전**: `fea5f1b2` (Rev.1) ← 본 문서가 대체합니다
- **판정 대상 리뷰**: `2b8e8ef2` (agy, `[VERDICT: PASS WITH CHALLENGE]`) — C-1 헤드리스 `max_columns` 우회 / C-2 미정의 헬퍼
- **테스트**: 현재 **330 passed** → 예상 **333** (Rev.1 의 332 에서 C-3 추가)

---

## A. 리뷰 판정 (Adjudication of Challenge `2b8e8ef2`)

### A-0. 판정 요약

| 챌린지 | 판정 | 근거 |
|---|:---:|---|
| **C-1** 헤드리스가 `max_columns` 를 우회 | ✅ **전면 수용 — 재현 및 처방 검증 완료** | `max_columns=2` + 헤드리스 N=4·6·8 이 전부 `right` 로 열을 무한 증식(실측). GUI 대조군 N=4 는 `overflow`. 제안된 패치를 프로토타입으로 전 행렬 검증 |
| **C-2** `_four_panes_two_columns()` 미정의 | ✅ **수용 — 같은 종류의 오류가 하나 더 있었음** | 지적대로 미정의. 추가로 Rev.1 스니펫의 **`SKILLS_DIR` 도 미정의**였음(파일에 module-level 상수 없음). 제안 헬퍼의 `-> Dict[str, Any]` 힌트는 `typing` import 없이는 **def 시점 NameError**(실측) |
| **C-3** 헤드리스 열 상한 가드 신설 | ✅ **수용 — 명칭·문안만 정밀화** | 채택. 다만 "strictly enforced" 는 실제 의미보다 강함 — §A-2 참조 |

리뷰어가 지적한 두 항목은 모두 실재하며, **C-1 은 Rev.1 이 놓친 구조적 결함**입니다. 아래에서 재현·검증하고, 리뷰어가 다루지 않은 두 가지를 덧붙여 정밀화합니다.

---

### A-1. C-1 재현 및 처방 검증

#### (1) 결함 재현

`max_columns=2` 를 준 상태에서 헤드리스 페인 수를 늘려가며 측정:

| N (헤드리스) | 현재 동작 | GUI 동등 상황 |
|---|---|---|
| 2 | `right` (2번째 열 개방) | `right` ✅ 일치 |
| 3 | `down` | `down` ✅ 일치 |
| **4** | 🔴 **`right`** (3번째 열 개방) | 🟢 **`overflow` / `max_columns_reached`** |
| 5 | `down` | `down` (`fill_singleton_column`) ✅ |
| **6, 8** | 🔴 **`right`** (열 무한 증식) | `overflow` |

`is_headless` 분기가 열 그룹핑과 `max_columns` 검사보다 **먼저 return** 하므로 상한이 한 번도 평가되지 않습니다. 리뷰어의 분석이 정확합니다.

#### (2) 제안 패치 전 행렬 검증

리뷰어가 제시한 `current_cols = n // 2` + even 분기 검사를 프로토타입으로 구현해 `max_columns` × N 전 조합을 확인:

```
max_columns=None -> N=2:righ  N=3:down  N=4:righ  N=5:down  N=6:righ  N=7:down  N=8:righ
max_columns=1    -> N=2:over  N=3:down  N=4:over  N=5:down  N=6:over  N=7:down  N=8:over
max_columns=2    -> N=2:righ  N=3:down  N=4:over  N=5:down  N=6:over  N=7:down  N=8:over
max_columns=3    -> N=2:righ  N=3:down  N=4:righ  N=5:down  N=6:over  N=7:down  N=8:over
```

- `max_columns=None` 행이 **현행과 완전히 동일** → 기존 `test_headless_0x0_transitions` 가 깨지지 않음이 보장됩니다.
- `max_columns=K` 는 정확히 K번째 열까지 허용하고 K+1번째를 열려는 시점에 overflow 합니다.

처방을 그대로 채택합니다.

### A-2. 정밀화 ① — `max_columns` 는 **불변식이 아니라 성장 가드**다

리뷰어는 C-3 테스트를 *"max_columns is strictly enforced"* 로 기술했습니다. 실측된 의미는 조금 다르며, 이 차이가 리뷰어의 "even 분기에서만 검사" 선택이 옳은 **이유**이기도 합니다.

GUI 모드에서 `max_columns=2` 인데 이미 3번째 열에 외톨이 페인이 있는 5-페인 워크스페이스를 넣으면:

```
5 panes / singleton  : direction=down  overflow=False  reason=fill_singleton_column
```

이미 상한을 넘긴 상태여도 **overflow 를 내지 않고 기존 열을 채웁니다**. 상한은 "새 열을 여는 것"을 막을 뿐, 이미 존재하는 열을 사후에 없앨 수는 없기 때문입니다. 외톨이 페인을 방치하는 것보다 채우는 편이 공간 효율이 낫습니다.

헤드리스의 홀수 분기(`down`)가 상한을 검사하지 않는 것은 GUI 의 `fill_singleton_column` 과 **정확히 같은 규칙**입니다. 즉 리뷰어의 처방은 임의의 선택이 아니라 **GUI 와의 대칭을 복원**하는 것이며, 이 점을 주석과 테스트 이름에 남겨야 다음 독자가 "홀수는 왜 검사 안 하나"를 다시 묻지 않습니다.

→ C-3 테스트 이름/독스트링을 `strictly enforced` 대신 **"opening a new column is blocked; filling an existing one is not"** 취지로 기술하도록 §3.2 에 반영했습니다.

### A-3. 정밀화 ② — `n // 2` 는 측정이 아니라 **추론**이다

GUI 경로는 페인의 x 좌표로 열을 **셉니다**(ground truth). 헤드리스에는 좌표가 없으므로 `n // 2` 로 **추정**합니다. 두 값은 성격이 다르며, 추정은 교대 불변식(홀수→down, 짝수→right)이 그 워크스페이스를 만들었을 때만 정확합니다.

페인이 닫혀 형상이 어긋난 경우(예: 2×2 그리드에서 하나가 닫혀 N=3)는 `n // 2 = 1` 로 실제 열 수(2)를 과소평가합니다. 그러나 **홀수는 어차피 `down` 으로 흡수**되고, 다음 짝수 N=4 에서 `n // 2 = 2` 가 되어 **자기 교정**됩니다. 따라서 실사용상 안전하지만, 이 근거를 코드 주석에 남기지 않으면 다음 사람이 "왜 열을 세지 않고 나누기를 하느냐"로 되돌릴 위험이 있습니다. §3.1 구현 사양에 주석 문안을 포함했습니다.

### A-4. 정밀화 ③ — 영향도 정정, 그러나 **같은 커밋에서 고쳐야 하는 이유**

리뷰어는 C-1 을 `Critical` 로 분류했습니다. 정확히는 **`--max-cols` 기본값이 `None` 이라 아무도 opt-in 하지 않은 지금은 잠복 상태**이며, 현재 사용자에게 발생 중인 장애가 아닙니다.

다만 이것이 심각도를 낮추지는 않습니다. **Rev.1 의 I-3b 가 바로 그 opt-in 경로(`MAM_MAX_PANE_COLS`)를 살리는 작업**이기 때문입니다. C-1 을 함께 고치지 않고 I-3b 만 적용하면, 이번 커밋이 **결함을 활성화하는 커밋**이 됩니다. 운영자가 `MAM_MAX_PANE_COLS=2` 를 설정하는 순간 GUI 는 상한을 지키고 헤드리스는 무한히 열을 늘리는 **모드 간 동작 분기**가 생깁니다.

→ C-1 과 I-3b 는 **분리 불가**하며, §6 실행 순서에서 같은 단계로 묶었습니다.

### A-5. C-2 수용 — 그리고 같은 종류의 오류가 하나 더 있었다

리뷰어 지적대로 `_four_panes_two_columns()` 는 어디에도 없습니다. 여기에 Rev.1 스니펫의 결함 두 가지를 스스로 덧붙입니다.

1. **`SKILLS_DIR` 도 미정의였습니다.** `tests/test_layout.py` 에는 module-level 상수가 하나도 없고, 기존 `test_cli_invocation_pipe` 는 테스트 내부에서 `skills_dir = os.path.abspath(".agents/skills")` 를 만들어 씁니다. Rev.1 스니펫은 정의되지 않은 두 이름에 의존했습니다.
2. **리뷰어가 제안한 헬퍼 시그니처도 그대로는 깨집니다.** `def _four_panes_two_columns() -> Dict[str, Any]:` 는 `typing` import 없이는 **정의 시점에** 터집니다.
   ```
   $ python -c "exec('def f() -> Dict[str, Any]:\n    return {}\n')"
   NameError at def time: name 'Dict' is not defined
   ```
   `tests/test_layout.py` 는 `typing` 을 import 하지 않으므로, 타입 힌트를 빼거나 import 를 추가해야 합니다. §3.2 는 힌트를 빼는 쪽을 택했습니다(파일 어디에도 타입 힌트를 쓰지 않는 관례와 일치).

---

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

| # | 변경 | 출처 |
|---|---|---|
| C-1 | **`compute_2xk_layout` 헤드리스 분기에 `max_columns` 검사 추가** — I-3b 와 동일 단계로 묶음 | 챌린지 C-1 + A-4 |
| C-2 | 헤드리스 상한 추론 근거(`n // 2`)와 GUI 대칭성을 **코드 주석으로 명문화** | A-2 / A-3 |
| C-3 | 신규 테스트 스니펫에서 **`_four_panes_two_columns()` 와 `skills_dir` 을 실제로 정의**, 타입 힌트 제거 | 챌린지 C-2 + A-5 |
| C-4 | **`test_headless_max_columns_growth_guard` 신설** (C-3 채택, 명칭·독스트링 정밀화) → 332 → **333** | 챌린지 C-3 + A-2 |
| C-5 | 뮤테이션 수용 기준에 **헤드리스 상한 2종** 추가 (6종 → 8종) | C-1 |
| C-6 | §6 실행 순서에서 C-1 과 I-3b 를 **분리 불가**로 명시 | A-4 |

Rev.1 의 §1 실측 원장(M-1~M-14), §2 I-2 사양, §3.1 `focused` 제거, §3.2 `--max-cols` env 배선 결정(bash 3.2 근거 포함), §3.3 앵커 주석, §4 문서화는 리뷰에서 승인되었으며 그대로 유지합니다.

---

## 0. 요약

I-1(`_pane_quiescent` 주석)은 **이미 `31b2d70` 에서 해결**되었습니다(M-1). 본 계획의 범위는 I-2 와 I-3 이며, 여기에 리뷰가 발굴한 **C-1(헤드리스 `max_columns` 우회)** 이 추가됩니다.

- **I-2 는 실측 가능한 계약을 세우는 일**입니다. 헤드리스 조기 탈출(≈1.2 s)은 현재 어떤 단언에도 걸려 있지 않아, 제거해도 6/6 초록인 채로 지연만 10배가 됩니다(M-4). 기능 단언으로는 잡을 수 없고 **시간 단언만이** 잡습니다.
- **I-3 는 죽은 표면을 정리하는 일**입니다. 그런데 그중 `--max-cols` 를 되살리는 작업이 **C-1 결함을 활성화**하므로, 두 작업은 반드시 함께 갑니다.

---

## 1. 실측 원장 (Measurement Ledger)

| # | 검증 | 방법 | 결과 |
|---|---|---|---|
| M-1 | I-1 선행 해결 여부 | `lib.sh:1578` | 🟢 `# empty_giveup: $SKS_EMPTY_GIVEUP (default: 3)` — 이미 정정됨 |
| M-2 | 현재 스위트 | `pytest tests/ -q` | **330 passed** |
| **M-3** | **헤드리스 정상 지연** | 독립 프로브 5회, `/usr/bin/time -p` | **1.22 / 1.24 / 1.24 / 1.23 / 1.24 s** (σ ≈ 0.01 s) |
| **M-4** | **헤드리스 회귀 지연** | 조기 giveup 제거 후 3회 | **10.21 / 10.25 / 10.21 s** — rc=0 이고 RPC 도 호출됨(**기능 단언 검출 불가**) |
| M-5 | B-19 스위트 소요 | `--durations=6` | 6 passed / 4.93 s. headless **1.20 s** |
| M-6 | bash 빈 배열 + `set -u` | 시스템 bash **3.2.57** | 🔴 `"${a[@]}"` → `unbound variable`. 🟢 `${a[@]+"${a[@]}"}` 정상 |
| M-7 | `--max-cols` CLI 현재 동작 | 4-pane 2열 + `--max-cols 2` | 🟢 `overflow p3` / `max_columns_reached` |
| M-8 | 환경변수 상속 선례 | `MAM_MIN_PANE_COLS=60` 만 설정 | 🟢 플래그 없이 반영됨 |
| M-9 | `extract_panes_and_focus` 호출처 | 전역 grep | `layout.py:78` 1곳. `tests/test_layout.py:9` 는 **import 만** |
| M-10 | `PaneInfo.focused` 판독처 | `grep -rn "\.focused\b"` | **0건** |
| M-11 | `PaneInfo` 이름 충돌 | `tests/fixtures/herdr_contract.json` | herdr RPC 타입. **무관, 건드리지 말 것** |
| M-12 | `sample_pane` 의 정체 | `lib.sh:415-427` | 워크스페이스의 **첫 번째 pane** — 포커스 무관 |
| M-13 | 레이아웃 env 문서화 | `grep -c … .mam.env.example` | **0** — `MAM_MIN_PANE_COLS`/`ROWS` 미문서화 |
| M-14 | 테스트 import 경로 | `tests/conftest.py:10-12` | `.agents/skills` 를 `sys.path` 주입 |
| **M-15** | **C-1 재현** | 헤드리스 N=2..8 × `max_columns=2` | 🔴 **N=4·6·8 전부 `right`** — 상한 미평가 |
| **M-16** | **GUI 대조군** | 4-pane 2열 × `max_columns=2` | 🟢 `overflow` / `max_columns_reached` |
| **M-17** | **GUI 외톨이 열 거동** | 5-pane(2열+외톨이) × `max_columns=2` | `down` / `fill_singleton_column` — **상한은 성장 가드**(A-2) |
| **M-18** | **C-1 패치 전 행렬** | 프로토타입 × `max_columns∈{None,1,2,3}` × N=2..8 | `None` 행이 **현행과 동일** → 기존 테스트 안전 |
| **M-19** | **`_four_panes_two_columns` 존재 여부** | `grep -rn tests/` | **없음**. 파일에 module-level 헬퍼가 **0개**, 전 테스트가 인라인 선언 |
| **M-20** | **미import 타입 힌트** | `exec("def f() -> Dict[str, Any]: ...")` | **정의 시점 NameError** |

---

## 2. I-2 — 헤드리스 조기 탈출 지연을 계약으로 고정

*(Rev.1 §2 에서 변경 없음 — 리뷰 승인)*

### 2.1 왜 시간 단언이어야 하는가

조기 giveup 을 제거해도 `send_keys_safe` 는 **rc=0 을 반환하고 `agent prompt` 도 호출**합니다(M-4). 현행 기능 단언이 전부 통과하고 달라지는 것은 **1.2 s → 10.2 s** 뿐입니다.

### 2.2 경계값 — 실측 근거

| 상태 | n | 범위 |
|---|---|---|
| 정상 | 5 | **1.22 – 1.24 s** |
| 회귀 | 3 | **10.21 – 10.25 s** |

`SKS_EMPTY_GIVEUP=3`, `interval=0.5` → 3번째 공백 캡처에서 sleep 없이 즉시 `return 2` 하므로 sleep 2회 = 1.0 s + bash 기동 0.2 s. 회귀 시 20 × 0.5 = 10.0 s. 헤드리스 경로는 `_pane_capture` 가 python3 를 띄우지 않아 측정이 거의 순수 sleep 입니다(σ ≈ 0.01 s).

**채택: 5.0 s** — 정상 대비 4배 여유, 회귀 대비 2배 마진.

### 2.3 구현 사양

```python
def test_bug4_headless_unobservable_fast_path(tmp_path):
    """Verify Bug 4 / R-1 + I-2: in headless mode where capture-pane is empty,
    send_keys_safe bypasses dialogs and succeeds immediately via the RPC fast-path.

    The elapsed-time bound is a contract, not a nicety: removing the
    SKS_EMPTY_GIVEUP early exit leaves every functional assertion green and only
    changes the wall clock (measured 1.22s -> 10.21s), so this is the sole
    assertion that can detect that regression.
    """
    test_script = f"""..."""          # 본문 변경 없음

    # SKS_* 는 pin 이 아니라 '제거'한다: lib.sh 의 기본값이 그대로 적용되어야
    # 기본값 자체의 회귀를 탐지할 수 있고, 동시에 개발자 셸에 남아 있는
    # 값 때문에 시간 단언이 흔들리지 않는다.
    env = {k: v for k, v in os.environ.items()
           if k not in ("SKS_QUIESCENT_TRIES", "SKS_QUIESCENT_INTERVAL", "SKS_EMPTY_GIVEUP")}

    t0 = time.perf_counter()
    res = subprocess.run(["bash", "-c", test_script], capture_output=True, text=True, env=env)
    elapsed = time.perf_counter() - t0

    assert res.returncode == 0, f"Headless send_keys_safe failed: {res.stderr}"
    assert "HEADLESS_OK" in res.stdout
    assert elapsed < 5.0, (
        f"headless fast-path took {elapsed:.2f}s (limit 5.0s) — the "
        f"SKS_EMPTY_GIVEUP early exit in _pane_quiescent is likely gone; "
        f"the full 10s quiescence window was consumed instead")
```

**필수**: 파일 상단 `import time` 추가 / 측정은 `subprocess.run` 만 감쌈 / `SKS_*` 는 **제거**(pin 금지) / 실패 메시지에 측정값과 원인 가설 포함.

---

## 3. I-3 + C-1 — 죽은 표면 정리 및 헤드리스 상한 복원

### 3.1 C-1 — 헤드리스 `max_columns` 검사 (**신규, I-3b 와 동일 단계**)

```python
    # Check for Headless mode: all panes have width <= 0 or height <= 0
    is_headless = all(p.width <= 0 or p.height <= 0 for p in panes)
    if is_headless:
        # Headless panes are all 0x0, so columns cannot be counted from geometry
        # the way the GUI path does. The alternation below (odd -> down,
        # even -> right) is what builds the grid, so while that invariant holds
        # the completed-column count is exactly n // 2. If panes were closed and
        # the shape drifted, an odd n is absorbed by the `down` branch and the
        # estimate self-corrects at the next even n.
        n = len(panes)
        anchor = default_anchor_id or panes[-1].pane_id
        if n % 2 == 1:
            # Filling an existing column never opens a new one, so max_columns is
            # deliberately NOT checked here -- this mirrors the GUI path, where
            # `fill_singleton_column` also ignores the cap. max_columns is a
            # growth guard, not an invariant over the existing layout.
            return LayoutDecision(target_pane_id=anchor, direction="down", reason="headless_odd_down")
        current_cols = n // 2
        if max_columns and current_cols >= max_columns:
            return LayoutDecision(target_pane_id=anchor, direction="overflow",
                                  is_overflow=True, reason="max_columns_reached")
        return LayoutDecision(target_pane_id=anchor, direction="right", reason="headless_even_right")
```

**동작 중립성**: `max_columns` 가 `None` 이면 분기가 통째로 건너뛰어져 현행과 완전히 동일합니다(M-18). 기존 `test_headless_0x0_transitions` 는 손대지 않아도 통과합니다.

**`reason` 문자열**: GUI 와 동일한 `max_columns_reached` 를 재사용합니다. 두 경로가 같은 사유를 내야 `--json` 소비자와 로그 분석에서 모드를 구분하지 않고 집계할 수 있습니다.

### 3.2 `PaneInfo.focused` — **제거**

*(Rev.1 §3.1 유지)* 판독처 0건(M-10), 호출처 1곳(M-9). `tests/test_layout.py:9,11` 은 import 만 하고 쓰지 않으며 CI flake8 가 `--select=E9,F63,F7,F82` 라 F401 을 보지 않아 통과해 왔습니다.

**헤드리스 앵커로 연결하는 대안은 기각**: (a) 2×K 엔진의 가치는 결정론인데 포커스는 사용자 상호작용 상태이고, (b) `lib.sh` 가 항상 `--sample-pane` 를 넘기므로 도달하지 않습니다. 애초에 `sample_pane` 은 "포커스된 pane" 이 아니라 워크스페이스의 첫 번째 pane 입니다(M-12).

```python
@dataclass
class PaneInfo:
    pane_id: str
    x: int
    y: int
    width: int
    height: int
    # NOTE: no `focused` field. The 2xK engine is deliberately geometry- and
    # structure-driven so that identical pane sets always yield identical
    # decisions. Focus is user-interaction state and would make the result
    # non-deterministic; herdr still reports it in the payload if ever needed.


def extract_panes(data: Dict[str, Any]) -> List[PaneInfo]:
    """Extract the pane list from a herdr layout JSON payload.

    Accepts all three shapes herdr 0.8 emits: result.layout.panes,
    result.panes, and a bare top-level panes array.
    """
```

동반: `compute_2xk_layout:78` → `panes = extract_panes(data)`, 미사용 `Tuple` import 정리, `tests/test_layout.py:9-11` 의 미사용 import 제거.

> ⚠️ `tests/fixtures/herdr_contract.json` 과 `tests/test_herdr_shim_contract.py:70` 의 `PaneInfo` 는 **herdr RPC 계약 타입**입니다(M-11). 건드리지 마십시오.

### 3.3 `--max-cols` env 배선

*(Rev.1 §3.2 유지)* CLI 는 이미 정상(M-7)이나 argparse 만 env 기본값이 없어 프로덕션 미도달입니다.

**`lib.sh` 조건부 배열 전달은 기각** — macOS 기본 bash **3.2.57** 에서 `set -euo pipefail` + 빈 배열은 즉사합니다(M-6). `${a[@]+"${a[@]}"}` 우회는 가능하나 대부분이 모르는 관용구를 핵심 경로에 심는 대가가 이익보다 큽니다. **argparse env 기본값 방식은 `lib.sh` 를 한 글자도 건드리지 않고** 같은 결과를 냅니다(M-8 선례).

```python
def _env_int(*names: str) -> Optional[int]:
    """First non-empty env var among *names, parsed as int. Bad values are
    ignored rather than raised: a typo in an operator's shell must not take the
    whole layout call down (lib.sh would silently fall back to 'right')."""
    for n in names:
        raw = os.environ.get(n, "").strip()
        if raw:
            try:
                return int(raw)
            except ValueError:
                return None
    return None


    parser.add_argument("--max-cols", type=int,
                        default=_env_int("MAM_MAX_COLS", "MAM_MAX_PANE_COLS"))
```

기본값은 계속 `None`(상한 없음) — **동작 중립**이며 운영자가 opt-in 할 때만 상한이 걸립니다.

### 3.4 헤드리스 앵커 주석 정정

*(Rev.1 §3.3 유지, §3.1 코드에 통합됨)* `panes[-1]` 폴백은 `lib.sh` 가 항상 `--sample-pane` 를 넘기므로 프로덕션에서 도달하지 않습니다. `no_panes_default` 분기(`layout.py:81-82`)에도 같은 취지의 한 줄을 권고합니다.

### 3.5 신규 테스트 3건 (C-2 / C-3 반영)

`tests/test_layout.py` 는 **module-level 헬퍼가 0개이고 모든 테스트가 페이로드를 인라인 선언**합니다(M-19). 새 헬퍼 1개를 도입하되 파일 관례를 존중해 타입 힌트는 붙이지 않습니다(M-20 — `typing` 미import 상태에서 힌트는 정의 시점에 터집니다).

```python
def _four_panes_two_columns():
    """GUI payload: 2 full columns x 2 rows (4 panes). Shared by the max-cols tests."""
    return {
        "result": {
            "panes": [
                {"pane_id": "p1", "rect": {"x": 0,   "y": 0,  "width": 100, "height": 40}},
                {"pane_id": "p2", "rect": {"x": 0,   "y": 40, "width": 100, "height": 40}},
                {"pane_id": "p3", "rect": {"x": 100, "y": 0,  "width": 100, "height": 40}},
                {"pane_id": "p4", "rect": {"x": 100, "y": 40, "width": 100, "height": 40}},
            ]
        }
    }


def test_cli_max_cols_flag_triggers_overflow():
    """CLI --max-cols reaches compute_2xk_layout (the lib.sh-facing path)."""
    payload = json.dumps(_four_panes_two_columns())
    skills_dir = os.path.abspath(".agents/skills")          # 파일 관례: 테스트 내부에서 계산
    env = {**os.environ, "PYTHONPATH": skills_dir}
    res = subprocess.run(
        [sys.executable, "-m", "lib_py.layout",
         "--min-cols", "30", "--min-rows", "20", "--max-cols", "2", "--json"],
        input=payload, capture_output=True, text=True, env=env)
    assert res.returncode == 0, res.stderr
    d = json.loads(res.stdout)
    assert d["direction"] == "overflow" and d["is_overflow"]
    assert d["reason"] == "max_columns_reached"


def test_env_max_cols_applies_without_flag():
    """MAM_MAX_PANE_COLS is honoured with no --max-cols flag, which is exactly
    how lib.sh invokes the module (lib.sh passes no --max-cols)."""
    payload = json.dumps(_four_panes_two_columns())
    skills_dir = os.path.abspath(".agents/skills")
    env = {**os.environ, "PYTHONPATH": skills_dir, "MAM_MAX_PANE_COLS": "2"}
    res = subprocess.run(
        [sys.executable, "-m", "lib_py.layout",
         "--min-cols", "30", "--min-rows", "20", "--json"],
        input=payload, capture_output=True, text=True, env=env)
    assert res.returncode == 0, res.stderr
    assert json.loads(res.stdout)["reason"] == "max_columns_reached"


def test_headless_max_columns_growth_guard():
    """C-1: headless mode must honour max_columns too.

    A headless 2xK grid completes n // 2 columns, so at n=4 with max_columns=2
    a further `right` split would open a third column and must overflow instead.
    Note the cap blocks *opening* a new column; it does not force an existing
    over-cap layout to shrink -- the odd-n `down` branch (and the GUI's
    fill_singleton_column) deliberately ignore it.
    """
    def headless(n):
        return {"result": {"panes": [
            {"pane_id": f"p{i}", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}
            for i in range(1, n + 1)]}}

    d4 = compute_2xk_layout(headless(4), max_columns=2)
    assert d4.is_overflow and d4.direction == "overflow"
    assert d4.reason == "max_columns_reached"

    # 상한 미만에서는 계속 성장한다
    d2 = compute_2xk_layout(headless(2), max_columns=2)
    assert d2.direction == "right" and not d2.is_overflow

    # 기존 열을 채우는 것은 막지 않는다 (GUI 의 fill_singleton_column 과 동일 규칙)
    d3 = compute_2xk_layout(headless(3), max_columns=2)
    assert d3.direction == "down" and not d3.is_overflow

    # max_columns 미지정 시 현행 동작 유지 (동작 중립성)
    assert compute_2xk_layout(headless(4)).direction == "right"
```

마지막 단언(동작 중립성)이 중요합니다 — C-1 패치가 기존 헤드리스 교대를 건드리지 않았음을 같은 테스트 안에서 못박습니다.

> 기존 `test_max_columns_limit` 은 동일한 페이로드를 인라인으로 갖고 있습니다. `_four_panes_two_columns()` 로 치환하면 중복이 줄지만, 통과 중인 테스트를 건드리는 것은 선택 사항으로 둡니다(§7 Q-5).

---

## 4. 문서화 — 레이아웃 튜너블

*(Rev.1 §4 유지)* `.mam.env.example` 에 `MAM_MIN_PANE_COLS` / `MAM_MIN_PANE_ROWS` 가 **한 건도 없습니다**(M-13). 직전 커밋에서 `SKS_*` 3종을 문서화한 것과 형평이 맞지 않고, `MAM_MAX_PANE_COLS` 를 새로 살리면서 이 공백을 두면 신규 변수만 미문서화로 추가됩니다.

```bash
# Minimum columns a pane must retain after a vertical split (2xK layout engine).
#default: 60
# MAM_MIN_PANE_COLS=60

# Minimum rows a pane must retain after a horizontal split (2xK layout engine).
#default: 20
# MAM_MIN_PANE_ROWS=20

# Maximum number of columns a workspace may grow to before the engine reports
# 'overflow' (which makes lib.sh create a fresh workspace instead of splitting).
# Applies to both measured (GUI) and headless 0x0 layouts.
#default: (unset -> no column cap)
# MAM_MAX_PANE_COLS=3
```

D-7 은 **설치 스크립트가 쓰는** 변수만 검사하므로 깨지지 않습니다. D-21/D-32 는 `MQTT_*` 대상이라 무관합니다 — 다만 §6 에서 배포 신선도 31건 재확인을 절차에 넣습니다.

---

## 5. 회귀 가드 및 수용 기준

| 가드 | 대상 | 뮤테이션 | 기대 |
|---|---|---|---|
| `test_bug4_headless_unobservable_fast_path` (I-2 강화) | 조기 탈출 지연 | 조기 `return 2` 제거 | **FAIL** |
| 동 | 동 | `SKS_EMPTY_GIVEUP` 기본값 3→20 | **FAIL** |
| `test_cli_max_cols_flag_triggers_overflow` (신규) | CLI 경로 | `--max-cols` argparse 인자 제거 | **FAIL** |
| `test_env_max_cols_applies_without_flag` (신규) | env 배선 | `default=_env_int(...)` → `default=None` | **FAIL** |
| **`test_headless_max_columns_growth_guard`** (신규) | **C-1** | 헤드리스 분기의 `max_columns` 검사 제거 | **FAIL** |
| 동 | **C-1 동작 중립성** | 헤드리스 홀수 분기에도 상한 검사 추가(과잉 교정) | **FAIL** (`d3` 단언) |
| 기존 `test_max_columns_limit` | Python API (GUI) | `max_columns` 분기 삭제 | **FAIL** |
| 기존 `test_headless_0x0_transitions` | 헤드리스 교대 | C-1 패치 적용 | **통과 유지**(회귀 없음 확인) |

**테스트 수 예상**: 330 → **333** (신규 3건, I-2 는 기존 테스트에 단언 추가).

---

## 6. 실행 순서 및 완료 정의

```
[1] I-2 시간 단언  ──> [2] I-3a focused 제거 ──> [3] C-1 + I-3b (분리 불가) ──> [4] 주석 ──> [5] 문서 ──> [6] 검증
    import time          PaneInfo/extract 정리    헤드리스 상한 + env 배선       앵커 주석    .mam.env       뮤테이션 8종
    env 필터링                                    + 신규 테스트 3건                          3종 추가      + 333 전건
```

> [!IMPORTANT]
> **[3] 은 쪼개지 않습니다.** I-3b 가 `MAM_MAX_PANE_COLS` opt-in 경로를 살리고, C-1 이 그 경로의 헤드리스 정합성을 보장합니다. I-3b 만 먼저 적용하면 이번 커밋이 **결함을 활성화하는 커밋**이 됩니다(A-4).

**단계별 확인**
1. I-2 적용 직후 `pytest tests/test_b19_headless_reconcile_fixes.py -q --durations=6` 로 headless 소요가 여전히 ≈1.2 s 인지 확인.
2. I-3a 는 개명이므로 **호출부 1곳(`layout.py:78`) + 테스트 import 1곳**만 수정(M-9).
3. C-1 적용 후 `MAM_MAX_PANE_COLS` **미설정 상태**에서 기존 `test_layout.py` 16건 전건 통과 → 동작 중립성 확인.

**DoD**
1. `pytest tests/ -q` → **333 passed**, exit 0.
2. §5 뮤테이션 8종이 각각 지정 테스트를 FAIL 시킴이 로그로 확인되고 원복됨.
3. `bash -n .agents/skills/lib.sh`, `py_compile lib_py/layout.py` 통과.
4. `pytest tests/test_deploy_freshness.py -q` → 31 passed.
5. `grep -rn "\.focused\b" .agents/skills/` → 0건, `grep -n "extract_panes_and_focus" tests/` → 0건.
6. GUI 와 헤드리스가 같은 `max_columns` 에서 **같은 시점에 overflow** 함을 수동 확인(4-pane / `max_columns=2` 양쪽 모두 `max_columns_reached`).
7. `git status --short` 에 의도한 5파일 외 변경 없음.

**게이트**: 2번 미충족 시 커밋 금지. 특히 I-2 시간 단언은 조기 giveup 제거 뮤테이션에서, C-1 가드는 헤드리스 상한 검사 제거 뮤테이션에서 **반드시 FAIL** 해야 합니다.

**범위 밖**: `--min-cols`/`--min-rows` 의 `lib.sh` 명시 전달 유지 여부, 열 상한 기본값 도입(Q-2), `IMPROVEMENTS.md` 항목 등록(Q-3), 혼합 모드(일부만 0×0) 처리(Q-6).

---

## 7. 열린 질문 (비차단)

| # | 질문 | 기본값(무응답 시) |
|---|---|---|
| **Q-1** | I-2 상한을 5.0 s 로 할 것인가? | **5.0 s** 유지 (실측 1.22–1.24 s 대비 4배, 회귀 10.2 s 대비 2배) |
| **Q-2** | `MAM_MAX_PANE_COLS` 에 기본 상한을 줄 것인가? | **주지 않음**(`None`). 기본값을 주면 기존 워크스페이스가 갑자기 분기 |
| **Q-3** | `IMPROVEMENTS.md` 에 등록할 것인가? | **B-20 항목에 후속 정리로 1–2줄 추가.** 단, **C-1 은 별도 문장으로 명시** — 잠복 결함이었고 opt-in 활성화와 함께 고쳐졌다는 사실은 기록 가치가 있음 |
| **Q-4** | `extract_panes_and_focus` 개명이 부담스러우면 이름 유지? | **개명 권고**(`extract_panes`). 반환이 튜플이 아니게 되므로 이름이 남으면 더 오해를 부름 |
| **Q-5** 🆕 | 기존 `test_max_columns_limit` 을 `_four_panes_two_columns()` 로 리팩터링할 것인가? | **하지 않음**. 통과 중인 테스트를 건드리는 위험 대비 이득이 중복 12줄 제거뿐 |
| **Q-6** 🆕 | 혼합 모드(일부 페인만 0×0)를 다룰 것인가? | **이번 범위 밖**. `is_headless` 가 `all(...)` 이라 혼합은 GUI 경로로 떨어지고 0-폭 페인이 한 열로 묶임. 실제 발생 사례가 관측되면 별도 과제로 |

---

## 8. 부록 — Creator 착수 체크리스트

- [ ] `tests/test_b19_headless_reconcile_fixes.py` 에 `import time` 추가
- [ ] `test_bug4_headless_unobservable_fast_path` 에 SKS_* 환경변수 **제거**(pin 아님) + `elapsed < 5.0` 단언 (§2.3)
- [ ] 뮤테이션: 조기 `return 2` 제거 → 해당 테스트 **FAIL** 확인 후 원복
- [ ] `lib_py/layout.py`: `PaneInfo.focused` 제거, `extract_panes_and_focus` → `extract_panes` 개명, `:78` 호출부 수정, `Tuple` import 정리 (§3.2)
- [ ] `tests/test_layout.py:9,11` 미사용 import 제거
- [ ] **`lib_py/layout.py`: 헤드리스 분기에 `max_columns` 검사 추가 + 근거 주석 (§3.1) — 아래 env 배선과 같은 커밋**
- [ ] `lib_py/layout.py`: `_env_int` 헬퍼 + `--max-cols` env 기본값 (§3.3). **`lib.sh` 는 변경하지 않음**
- [ ] `tests/test_layout.py` 에 `_four_panes_two_columns()` **정의** + 신규 테스트 **3건** 추가 (§3.5) — 타입 힌트 금지(M-20), `skills_dir` 은 테스트 내부에서 계산
- [ ] `lib_py/layout.py`: `no_panes_default` 분기 주석 보강 (§3.4)
- [ ] `.mam.env.example` 에 `MAM_MIN_PANE_COLS` / `MAM_MIN_PANE_ROWS` / `MAM_MAX_PANE_COLS` 문서화 (§4)
- [ ] `pytest tests/ -q` → **333 passed**
- [ ] `pytest tests/test_deploy_freshness.py -q` → 31 passed
- [ ] §5 뮤테이션 **8종** 전건 FAIL 확인 후 원복, 로그 첨부
- [ ] GUI/헤드리스가 `max_columns=2` + 4페인에서 **동일하게** `max_columns_reached` 를 내는지 수동 확인
- [ ] ⚠️ `tests/fixtures/herdr_contract.json` 의 `PaneInfo` 는 **herdr RPC 타입** — 건드리지 말 것 (M-11)
