From b72412be95f9bd2880e5ffcbbe393aea97a453a8 Mon Sep 17 00:00:00 2001 From: Godopu Date: Mon, 24 Aug 2026 15:06:02 +0900 Subject: [PATCH] feat(layout): reduce default MAM_MIN_PANE_COLS to 40 for single workspace 2xK multi-pane tiling --- .../report-ddc8d9f1.md | 169 ++++++++++++++++++ .../reviewer-cline-01/report-bb360685.md | 103 +++++++++++ .agents/skills/lib.sh | 2 +- .agents/skills/lib_py/layout.py | 6 +- .mam.env.example | 4 +- tests/test_a4_adapter_contract.py | 2 +- tests/test_layout.py | 145 ++++++++++++++- tests/test_tier1_unit.py | 86 +++++++++ 8 files changed, 508 insertions(+), 9 deletions(-) create mode 100644 .agents/reports/planner-reviewer-claude-01/report-ddc8d9f1.md create mode 100644 .agents/reports/reviewer-cline-01/report-bb360685.md diff --git a/.agents/reports/planner-reviewer-claude-01/report-ddc8d9f1.md b/.agents/reports/planner-reviewer-claude-01/report-ddc8d9f1.md new file mode 100644 index 0000000..e7ba0e2 --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-ddc8d9f1.md @@ -0,0 +1,169 @@ +# πŸ” Cross Code Review β€” Job ddc8d9f1 + +- **Reviewer**: `planner-reviewer-claude-01` (role: `planner,reviewer`) +- **Target**: 2xK grid layout engine β€” `MAM_MIN_PANE_COLS` κΈ°λ³Έκ°’ 60 β†’ 40 및 단일 μ›Œν¬μŠ€νŽ˜μ΄μŠ€ 닀쀑 페인 타일링 +- **Reviewed files**: `.agents/skills/lib.sh`, `.agents/skills/lib_py/layout.py`, `.mam.env.example`, `tests/test_layout.py`, `tests/test_tier1_unit.py`, `tests/test_a4_adapter_contract.py` + +--- + +## ⚠️ 검증 λ²”μœ„ κ³ μ§€ (Verification Scope Disclaimer) + +**λ³Έ μ„Έμ…˜μ—μ„œ `pytest` μŠ€μœ„νŠΈλ₯Ό μ‹€ν–‰ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€.** μ‹€ν–‰ μ‹œλ„κ°€ μ€‘λ‹¨λ˜μ–΄(μ‚¬μš©μž κ±°λΆ€) λΈŒλ¦¬ν”„ 4번 μš”κ΅¬μ‚¬ν•­ *"Ensure full pytest test suite passes"* λŠ” **μ‹€μΈ‘μœΌλ‘œ ν™•μΈλ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.** + +λ”°λΌμ„œ μ•„λž˜ νŒμ •μ€ λ‹€μŒ λ²”μœ„λ‘œ ν•œμ •λ©λ‹ˆλ‹€: +- βœ… μ†ŒμŠ€ 정적 뢄석 (`layout.py` 전체 둜직 νŒλ…) +- βœ… μ‹ κ·œ ν…ŒμŠ€νŠΈμ˜ **λͺ¨λ“  단언을 μ—”μ§„ 뢄기에 λŒ€μž…ν•œ μˆ˜λ™ 트레이슀** +- βœ… `grep` 기반 μƒμˆ˜ λ“œλ¦¬ν”„νŠΈ μ „μˆ˜ 쑰사 +- ❌ **ν…ŒμŠ€νŠΈ μ‹€ν–‰ κ²°κ³Ό (λ―Έμˆ˜ν–‰)** + +μˆ˜μΉ˜Β·λΆ„κΈ° 좔적은 결정둠적 μ •μˆ˜ 연산이라 μˆ˜λ™ κ²€μ¦μ˜ 신뒰도가 λ†’μ§€λ§Œ, μ‹€ν–‰ 확인은 λ³„λ„λ‘œ 이루어져야 ν•©λ‹ˆλ‹€. Β§5에 μž”μ—¬ ν•­λͺ©μ„ λͺ…μ‹œν–ˆμŠ΅λ‹ˆλ‹€. + +--- + +## 1. λ³€κ²½ μš”μ•½ + +| μœ„μΉ˜ | λ³€κ²½ | νŒμ • | +|---|---|---| +| `lib_py/layout.py:73` | `compute_2xk_layout(min_cols=60)` β†’ `40` | βœ… | +| `lib_py/layout.py:201` | `--min-cols` κΈ°λ³Έκ°’ `_env_int(..., default=60)` β†’ `40` | βœ… | +| `lib_py/layout.py:179` | λ…μŠ€νŠΈλ§ `60 default` β†’ `40 default` | βœ… | +| `lib.sh:432` | `${MAM_MIN_PANE_COLS:-60}` β†’ `:-40` | βœ… | +| `.mam.env.example:132-133` | 주석 `#default: 60` 및 μ˜ˆμ‹œ `=60` β†’ `40` | βœ… | +| `tests/test_layout.py:290` | lib.sh μ†ŒμŠ€ μŠ€λ‹ˆνŽ« κ°€λ“œ λ¬Έμžμ—΄ 동기화 | βœ… | + +**3쀑 κΈ°λ³Έκ°’ 동기화 확인**: 이 μ½”λ“œλ² μ΄μŠ€λŠ” λ™μΌν•œ 기본값을 **μ„Έ κ³³**(shell νŒŒλΌλ―Έν„° ν™•μž₯, Python μ‹œκ·Έλ‹ˆμ²˜, Python argparse)에 쀑볡 λ³΄μœ ν•©λ‹ˆλ‹€. μ„Έ κ³³ λͺ¨λ‘ 40으둜 μΌμΉ˜ν•˜λ©° `.mam.env.example` λ¬Έμ„œκ°’κΉŒμ§€ 4쀑 μΌμΉ˜ν•©λ‹ˆλ‹€. λ“œλ¦¬ν”„νŠΈ μ—†μŒ. + +> **μ°Έκ³ **: `lib.sh:432` λŠ” 항상 `--min-cols` λ₯Ό **λͺ…μ‹œ 전달**ν•˜λ―€λ‘œ μ‹€μš΄μ˜ κ²½λ‘œμ—μ„œ `layout.py:201` 의 argparse 기본값은 λ„λ‹¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. 201번 쀄은 CLI 직접 ν˜ΈμΆœΒ·ν…ŒμŠ€νŠΈ 경둜용 fallback μž…λ‹ˆλ‹€. 두 값이 μ–΄κΈ‹λ‚˜λ„ μ¦‰μ‹œ λ“œλŸ¬λ‚˜μ§€ μ•ŠλŠ” κ΅¬μ‘°μ΄λ―€λ‘œ Β§4에 κ°€λ“œ μ œμ•ˆμ„ λ‚¨κΉλ‹ˆλ‹€. + +--- + +## 2. 둜직 μ •ν•©μ„± β€” μ‹ κ·œ ν…ŒμŠ€νŠΈ μˆ˜λ™ 트레이슀 + +`compute_2xk_layout` 의 λΆ„κΈ°λ₯Ό μ‹ κ·œ 단언에 κ·ΈλŒ€λ‘œ λŒ€μž…ν•΄ 전건 κ²€μ¦ν–ˆμŠ΅λ‹ˆλ‹€. 폭 νŒμ •μ€ `layout.py:169` 의 `width // 2 < min_cols` 단일 κ²Œμ΄νŠΈμž…λ‹ˆλ‹€. + +### 2.1 경계값 (80 / 79 cols) + +| μž…λ ₯ | 계산 | 도달 λΆ„κΈ° | κΈ°λŒ€ | μ‹€μ œ | +|---|---|---|---|---| +| 2페인 Γ— w=80 (x=0 동일열) | `80 // 2 = 40`, `40 < 40` = False | `:172 new_column_right` | `right`, not overflow | βœ… 일치 | +| 2페인 Γ— w=79 | `79 // 2 = 39`, `39 < 40` = True | `:170 column_width_overflow` | `overflow` | βœ… 일치 | + +**80이 μ •ν™•ν•œ ν•˜ν•œ**μž„μ΄ ν™•μΈλ©λ‹ˆλ‹€(`>= 80` μ—μ„œ λΆ„ν•  κ°€λŠ₯). λΈŒλ¦¬ν”„ 2번 μš”κ΅¬μ‚¬ν•­ *"width >= 80 μ—μ„œ μ‘°κΈ° overflow κΈˆμ§€"* λŠ” μƒμˆ˜ λ³€κ²½λ§ŒμœΌλ‘œ μ‚°μˆ μ μœΌλ‘œ 좩쑱되며, 별도 λΆ„κΈ° μΆ”κ°€κ°€ λΆˆν•„μš”ν•©λ‹ˆλ‹€ β€” **μ—”μ§„ 둜직 무변경은 μ˜¬λ°”λ₯Έ νŒλ‹¨**μž…λ‹ˆλ‹€. λΆˆν•„μš”ν•œ 특수 μΌ€μ΄μŠ€λ₯Ό λ„£μ§€ μ•Šμ€ 점을 긍정 ν‰κ°€ν•©λ‹ˆλ‹€. + +### 2.2 90 / 100 col 단일 μ›Œν¬μŠ€νŽ˜μ΄μŠ€ 타일링 (1β†’2β†’3β†’4β†’overflow) + +`total_w ∈ {90, 100}`, `half_w = total_w // 2 ∈ {45, 50}` κΈ°μ€€ μ „ 단계 좔적: + +| 단계 | μž…λ ₯ ν˜•μƒ | νŒμ • 경둜 | κ²°κ³Ό | +|---|---|---|---| +| 1β†’2 | 1페인 `wΓ—40` | `:94` `40//2 = 20 >= min_rows 20` β†’ False(μ œμ•½ μ•„λ‹˜) β†’ `:102` | `down` / `single_pane_split_down`, target `p1` βœ… | +| 2β†’3 | 2페인 x=0 단일열 | singleton μ—†μŒ β†’ `:169` `45//2=22`? **μ•„λ‹ˆμ˜€** β€” 이 μ‹œμ  페인 폭은 아직 `total_w`(90/100) β†’ `90//2=45 >= 40` | `right` / `new_column_right`, target `p1`(`columns[-1][0]`) βœ… | +| 3β†’4 | `[p1,p2]` @x=0, `[p3]` @x=half_w | `:148-154` singleton μ—΄ `[p3]` 탐지 β†’ 높이 `40//2=20 >= 20` | `down` / `fill_singleton_column`, target `p3` βœ… | +| 4β†’5 | 2μ—΄ Γ— 2페인 μ™„μ„± | singleton μ—†μŒ, `max_columns=None` β†’ `:169` `45//2=22 < 40` (100col: `50//2=25 < 40`) | `overflow` / `column_width_overflow` βœ… | + +**핡심 확인 사항 2건**: +1. **1β†’2 λ‹¨κ³„μ˜ 높이 경계**: `height=40` μ—μ„œ `40 // 2 = 20`, `min_rows=20` κ³Ό **κ°™μŒ**. `:94` 쑰건은 `< min_rows` μ΄λ―€λ‘œ False β†’ μ •μƒμ μœΌλ‘œ `down` μ§„μž…. `<=` μ˜€λ‹€λ©΄ μ˜€λΆ„κΈ°ν–ˆμ„ μ§€μ μœΌλ‘œ, ν…ŒμŠ€νŠΈκ°€ 이 경계λ₯Ό μ •ν™•νžˆ 짚고 μžˆμŠ΅λ‹ˆλ‹€. +2. **4νŽ˜μΈμ—μ„œμ˜ μ˜λ„μ  overflow**: `min_cols=40` μ—μ„œ 90~100col μ›Œν¬μŠ€νŽ˜μ΄μŠ€λŠ” **μ΅œλŒ€ 4μ—μ΄μ „νŠΈ**κ°€ μƒν•œμ΄λ©° 5λ²ˆμ§ΈλŠ” μƒˆ μ›Œν¬μŠ€νŽ˜μ΄μŠ€λ‘œ λ„˜μ–΄κ°‘λ‹ˆλ‹€. λΈŒλ¦¬ν”„ λͺ©ν‘œ(*"3-4 agents in ~100-col terminal"*)와 μ •ν™•νžˆ λΆ€ν•©ν•˜κ³ , ν…ŒμŠ€νŠΈκ°€ 이 μƒν•œμ„ λͺ…μ‹œμ μœΌλ‘œ κ³ μ •ν•˜κ³  μžˆμ–΄ ν–₯ν›„ νšŒκ·€ μ‹œ μ¦‰μ‹œ κ²€μΆœλ©λ‹ˆλ‹€. βœ… + +### 2.3 컬럼 κ·Έλ£Ήν•‘ μ •ν•©μ„± + +`:129-139` 의 xμ’Œν‘œ 퍼지 κ·Έλ£Ήν•‘(μž„κ³„ 2col)에 μ‹ κ·œ ν”½μŠ€μ²˜ λŒ€μž… μ‹œ: +- 90col: x ∈ {0, 45} β†’ `|0-45| = 45 > 2` β†’ 2개 μ—΄λ‘œ μ •ν™•νžˆ 뢄리 βœ… +- 100col: x ∈ {0, 50} β†’ 동일 βœ… + +퍼지 μž„κ³„κ°’ 2와 μΆ©λŒν•˜λŠ” μ’Œν‘œκ°€ μ—†μ–΄ κ·Έλ£Ήν•‘ μ˜€λΆ„λ₯˜ μœ„ν—˜μ΄ μ—†μŠ΅λ‹ˆλ‹€. + +--- + +## 3. νšŒκ·€ 영ν–₯ 뢄석 (μœ μ‹€ 관점) + +κΈ°λ³Έκ°’ 변경은 **기본값에 μ˜μ‘΄ν•˜λŠ” κΈ°μ‘΄ ν…ŒμŠ€νŠΈ**μ—λ§Œ νŒŒκΈ‰λ©λ‹ˆλ‹€. μ „μˆ˜ 쑰사 κ²°κ³Ό: + +| κΈ°μ‘΄ ν…ŒμŠ€νŠΈ | κΈ°λ³Έκ°’ 의쑴 μ—¬λΆ€ | 영ν–₯ | +|---|---|---| +| `test_layout.py:28~262` (8건) | `min_cols=60` **λͺ…μ‹œ 전달** | 영ν–₯ μ—†μŒ βœ… | +| `test_layout.py:137,191` | `min_cols=30` λͺ…μ‹œ | 영ν–₯ μ—†μŒ βœ… | +| `test_layout.py:207` (subprocess) | `--min-cols 60` λͺ…μ‹œ | 영ν–₯ μ—†μŒ βœ… | +| `test_layout.py:358,374` | `--min-cols 30` λͺ…μ‹œ | 영ν–₯ μ—†μŒ βœ… | +| `test_j1_env_zero_min_cols_matches_flag_zero` | `_ZERO_TRAP` κΈ°λ³Έκ°’ μ‹€ν–‰ 포함 | **영ν–₯ κ²€ν†  ν•„μš” β†’ μ•„λž˜** | +| `test_j1b_invalid_alias_does_not_shadow...` | κΈ°λ³Έκ°’ μ‹€ν–‰ 비ꡐ | 동일 βœ… | + +**J-1 계열 μ •λ°€ κ²€ν† ** (`test_layout.py:432` 주석 κΈ°μ€€ `_ZERO_TRAP` = 단일 페인 `50Γ—30`): +- `height // 2 = 15 < min_rows 20` β†’ `:94` μ œμ•½ λΆ„κΈ° μ§„μž… +- `width // 2 = 25` λ₯Ό `min_cols` 와 비ꡐ: κΈ°μ‘΄ `25 < 60` β†’ overflow / μ‹ κ·œ `25 < 40` β†’ **overflow (동일)** +- 즉 기본값이 60이든 40이든 `_ZERO_TRAP` 의 κ²°κ³ΌλŠ” `single_pane_overflow` 둜 λΆˆλ³€. **J-1/C-2 λΆˆλ³€μ‹ 보쑴 확인** βœ… + +λ˜ν•œ J-1bλŠ” "κΈ°λ³Έκ°’ μ‹€ν–‰ == κΈ°λ³Έκ°’ μ‹€ν–‰" ν˜•νƒœμ˜ 자기참쑰 비ꡐ라 κΈ°λ³Έκ°’ μžμ²΄μ™€ λ¬΄κ΄€ν•˜κ²Œ μ„±λ¦½ν•©λ‹ˆλ‹€. + +`test_layout.py:290` 의 lib.sh μ†ŒμŠ€ μŠ€λ‹ˆνŽ« κ°€λ“œλŠ” **λ¬Έμžμ—΄ μ™„μ „ 일치** κ²€μ‚¬μ΄λ―€λ‘œ `lib.sh:432` 와 ν•¨κ»˜ κ°±μ‹ λ˜μ§€ μ•Šμ•˜λ‹€λ©΄ μ¦‰μ‹œ μ‹€νŒ¨ν–ˆμ„ ν•­λͺ©μž…λ‹ˆλ‹€. μ–‘μͺ½ λͺ¨λ‘ `:-40` 으둜 λ™κΈ°ν™”λ˜μ–΄ μžˆμŒμ„ λŒ€μ‘° ν™•μΈν–ˆμŠ΅λ‹ˆλ‹€ βœ… + +--- + +## 4. 지적 사항 (λͺ¨λ‘ 비차단 / Non-blocking) + +차단 결함(P0/P1)은 λ°œκ²¬λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. μ•„λž˜λŠ” κ°œμ„  κΆŒκ³ μž…λ‹ˆλ‹€. + +### 🟑 N-1 (P3) β€” `test_herdr_shim_contract.py:100` 의 `MAM_MIN_PANE_COLS=60` λ―Έκ²€ν†  +`tests/test_herdr_shim_contract.py:92,100` 의 H-13 μΌ€μ΄μŠ€κ°€ `export MAM_MIN_PANE_COLS=60` 을 μ‚¬μš©ν•©λ‹ˆλ‹€. μ΄λŠ” **ν™˜κ²½λ³€μˆ˜ μ˜€λ²„λΌμ΄λ“œ λ™μž‘ 자체**λ₯Ό κ²€μ¦ν•˜λŠ” μΌ€μ΄μŠ€μ΄λ―€λ‘œ κΈ°λ³Έκ°’ λ³€κ²½κ³Ό λ…Όλ¦¬μ μœΌλ‘œ 독립이며(λͺ…μ‹œ μ˜€λ²„λΌμ΄λ“œ 경둜), 정상 톡과가 μ˜ˆμƒλ©λ‹ˆλ‹€. λ‹€λ§Œ 파일 본문을 μ—΄λžŒν•˜μ§€ λͺ»ν•΄ **단언 λ‚΄μš©κΉŒμ§€λŠ” ν™•μΈν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€.** + +**κ°œμ„  λ°©ν–₯**: 이 ν…ŒμŠ€νŠΈκ°€ "60이 μ•„λ‹Œ 값이 적용됨"을 κ²€μ¦ν•˜λŠ” μ˜λ„λΌλ©΄, 이제 κΈ°λ³Έκ°’ 40κ³Ό μ˜€λ²„λΌμ΄λ“œ κ°’ 60이 λͺ…ν™•νžˆ κ΅¬λΆ„λ˜μ–΄ 였히렀 λŒ€μ‘°κ°€ μ„ λͺ…ν•΄μ§‘λ‹ˆλ‹€. ν™•μΈλ§Œ κΆŒκ³ ν•©λ‹ˆλ‹€. + +### 🟑 N-2 (P3) β€” `IMPROVEMENTS.md:49` 의 `min_cols=60` μž”μ‘΄ +``` +IMPROVEMENTS.md:49: ... 해상도 μ˜€λ²„ν”Œλ‘œ κ°€λ“œ(`min_cols=60`, `min_rows=20`) ... +``` +ν•΄λ‹Ή 쀄은 **μ—”μ§„ 졜초 λ„μž… μ‹œμ μ„ κΈ°λ‘ν•œ λ³€κ²½ 이λ ₯**μ΄λ―€λ‘œ λ‹Ήμ‹œ κ°’ 60을 λ‚¨κΈ°λŠ” 것이 이λ ₯ λ¬Έμ„œλ‘œμ„œλŠ” μ •ν™•ν•©λ‹ˆλ‹€. λ‹€λ§Œ ν˜„μž¬ 이 μ €μž₯μ†Œμ—μ„œ **60을 기본값이라 μ„œμˆ ν•˜λŠ” μœ μΌν•œ λ¬Έμ„œ**κ°€ λ˜μ—ˆμŠ΅λ‹ˆλ‹€. + +**κ°œμ„  λ°©ν–₯ (택1)**: (a) κ·ΈλŒ€λ‘œ λ‘λ˜ 이번 변경을 `IMPROVEMENTS.md` μ‹ κ·œ ν•­λͺ©μœΌλ‘œ μΆ”κ°€ν•˜μ—¬ 60β†’40 μ „ν™˜ 이λ ₯을 μž‡λŠ”λ‹€ β€” **ꢌμž₯**. (b) ν•΄λ‹Ή 쀄에 `(ν˜„ν–‰ 40, 작 ddc8d9f1μ—μ„œ λ³€κ²½)` 각주λ₯Ό 뢙인닀. 이λ ₯ λ¬Έμ„œλ₯Ό μ†ŒκΈ‰ μˆ˜μ •ν•˜λŠ” 방식은 ꢌμž₯ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. + +### 🟑 N-3 (P3) β€” κΈ°λ³Έκ°’ 4쀑 쀑볡에 λŒ€ν•œ νŒŒλ¦¬ν‹° κ°€λ“œ λΆ€μž¬ +동일 μƒμˆ˜κ°€ `lib.sh:432` / `layout.py:73` / `layout.py:201` / `.mam.env.example:133` 4곳에 λ¬Έμžμ—΄λ‘œ 쀑볡 μ‘΄μž¬ν•©λ‹ˆλ‹€. `test_layout.py:290` 이 lib.shβ†”ν…ŒμŠ€νŠΈ μŠ€λ‹ˆνŽ« 쌍만 κ³ μ •ν•  뿐, **`layout.py:73` μ‹œκ·Έλ‹ˆμ²˜ κΈ°λ³Έκ°’κ³Ό `layout.py:201` argparse κΈ°λ³Έκ°’μ˜ μΌμΉ˜λŠ” μ–΄λ–€ ν…ŒμŠ€νŠΈλ„ κ°•μ œν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.** 두 값이 μ–΄κΈ‹λ‚˜λ©΄ CLI κ²½λ‘œμ™€ 라이브러리 μž„ν¬νŠΈ κ²½λ‘œκ°€ 쑰용히 κ°ˆλΌμ§‘λ‹ˆλ‹€. + +**κ°œμ„  λ°©ν–₯ (κ΅¬μ²΄μ•ˆ)**: +```python +# tests/test_layout.py +import inspect +from lib_py.layout import compute_2xk_layout + +def test_default_min_cols_parity_across_entrypoints(): + """μ‹œκ·Έλ‹ˆμ²˜ κΈ°λ³Έκ°’ == argparse κΈ°λ³Έκ°’ == lib.sh fallback.""" + sig_default = inspect.signature(compute_2xk_layout).parameters["min_cols"].default + assert sig_default == 40 + # argparse 경둜: env λ―Έμ„€μ • μ‹œ 동일 결정을 λ‚΄μ•Ό 함 + assert _run_layout(_ZERO_TRAP) == _run_layout(_ZERO_TRAP, ("--min-cols", str(sig_default))) + # lib.sh fallback λ¬Έμžμ—΄ + lib_sh = (REPO_ROOT / ".agents/skills/lib.sh").read_text() + assert f'${{MAM_MIN_PANE_COLS:-{sig_default}}}' in lib_sh +``` +μ΄λŠ” 이전 μž‘μ—μ„œ `ready_tokens` κ°€ `lib.sh`/`claude.py` μ–‘μͺ½μ— μ€‘λ³΅λœ 것과 **동일 μœ ν˜•μ˜ ꡬ쑰적 취약점**이며, 같은 처방이 μ μš©λ©λ‹ˆλ‹€. 별도 작으둜 뢄리해도 λ¬΄λ°©ν•©λ‹ˆλ‹€. + +### 🟒 N-4 (P4) β€” μ›Œν‚ΉνŠΈλ¦¬ μœ„μƒ +`git status` 에 `m nats-docker` (μ„œλΈŒλͺ¨λ“ˆ dirty, `5db38da...-dirty`) κ°€ ν¬ν•¨λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€. λ³Έ λ³€κ²½κ³Ό λ¬΄κ΄€ν•œ μ˜€μ—Όμ΄λ©° 컀밋 μ „ 정리λ₯Ό κΆŒκ³ ν•©λ‹ˆλ‹€. λ˜ν•œ `tests/test_layout.py` 말미에 빈 쀄 3개(`+++`)κ°€ μΆ”κ°€λ˜μ–΄ μžˆμ–΄ PEP8 κ΄€μ μ˜ μ‚¬μ†Œν•œ 정리 μ—¬μ§€κ°€ μžˆμŠ΅λ‹ˆλ‹€. κΈ°λŠ₯ 영ν–₯ μ—†μŒ. + +### ℹ️ N-5 (정보) β€” λˆ„μ  diff λ‚΄ `test_a4_adapter_contract.py` λ³€κ²½ +`ready_tokens` 에 `Claude Code|Opus|Sonnet|Haiku` λ₯Ό μΆ”κ°€ν•œ 직전 작의 변경뢄이 λˆ„μ  diff에 ν¬ν•¨λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€. 계약 ν…ŒμŠ€νŠΈμ˜ κΈ°λŒ€κ°’μ΄ `lib.sh` / `claude.py` μ–‘μͺ½ κ΅¬ν˜„κ³Ό 3자 μΌμΉ˜ν•¨μ„ λŒ€μ‘° ν™•μΈν–ˆμŠ΅λ‹ˆλ‹€ βœ… (λ³Έ 작 λ²”μœ„ μ™Έ) + +--- + +## 5. μž”μ—¬ 검증 ν•­λͺ© (Outstanding) + +| # | ν•­λͺ© | μƒνƒœ | +|---|---|---| +| V-1 | `pytest tests/ -q` 전체 톡과 | ❌ **λ―Έμˆ˜ν–‰** β€” λ³Έ μ„Έμ…˜μ—μ„œ μ‹€ν–‰ 쀑단됨 | +| V-2 | `test_herdr_shim_contract.py` H-13 단언 λ‚΄μš© | ⚠️ λ―Έμ—΄λžŒ (영ν–₯ μ—†μŒμœΌλ‘œ μΆ”μ •, N-1) | + +**V-1은 λ¨Έμ§€ μ „ λ°˜λ“œμ‹œ μ‹€μΈ‘λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€.** 정적 뢄석상 μ‹€νŒ¨λ₯Ό μœ λ°œν•  μš”μΈμ€ λ°œκ²¬ν•˜μ§€ λͺ»ν–ˆμœΌλ‚˜(Β§3 νšŒκ·€ 영ν–₯ 전무), μ΄λŠ” μ˜ˆμΈ‘μ΄μ§€ 관츑이 μ•„λ‹™λ‹ˆλ‹€. + +--- + +## 6. 총평 + +변경은 **μƒμˆ˜ 1개의 κ°’ μ‘°μ •κ³Ό 그에 λŒ€ν•œ 4쀑 동기화**λΌλŠ” μ΅œμ†Œ ν‘œλ©΄μ μ„ μ •ν™•νžˆ μ§€μΌ°μŠ΅λ‹ˆλ‹€. μ—”μ§„ λΆ„κΈ° λ‘œμ§μ„ κ±΄λ“œλ¦¬μ§€ μ•Šκ³  λΈŒλ¦¬ν”„μ˜ 4개 μš”κ΅¬μ‚¬ν•­μ„ μΆ©μ‘±ν•œ 점, 특히 μš”κ΅¬μ‚¬ν•­ 2λ₯Ό μœ„ν•΄ λΆˆν•„μš”ν•œ 특수 λΆ„κΈ°λ₯Ό μΆ”κ°€ν•˜μ§€ μ•Šκ³  μ‚°μˆ λ‘œ ν•΄μ†Œν•œ 점이 μ„€κ³„μ μœΌλ‘œ κ±΄μ „ν•©λ‹ˆλ‹€. + +μ‹ κ·œ ν…ŒμŠ€νŠΈλŠ” λ‹¨μˆœ happy-path에 λ¨Έλ¬Όμ§€ μ•Šκ³  **80/79 경계**, **height 40//2 == min_rows 20 동등 경계**, **4페인 μƒν•œ ν›„ overflow** λΌλŠ” μ„Έ 개의 μ‹€μ§ˆμ  경계λ₯Ό κ³ μ •ν•©λ‹ˆλ‹€. κΈ°μ‘΄ J-1/C-2 λΆˆλ³€μ‹λ„ λ³΄μ‘΄λ©λ‹ˆλ‹€. + +지적 사항 4건은 λͺ¨λ‘ P3 μ΄ν•˜μ΄λ©° λ¬Έμ„œ 이λ ₯Β·ν…ŒμŠ€νŠΈ μœ„μƒΒ·μ›Œν‚ΉνŠΈλ¦¬ 정리 λ²”μ£Όλ‘œ, μ–΄λŠ 것도 ν˜„μž¬ λ™μž‘μ„ ν•΄μΉ˜κ±°λ‚˜ 결함을 μ€νν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. 섀계 λ³€κ²½μ΄λ‚˜ μž¬κ³„νšμ΄ ν•„μš”ν•œ μ‚¬μ•ˆμ€ μ—†μŠ΅λ‹ˆλ‹€. + +**단, λ³Έ PASSλŠ” Β§5 V-1(전체 ν…ŒμŠ€νŠΈ μ‹€ν–‰) 이 λ³„λ„λ‘œ ν™•μΈλœλ‹€λŠ” μ „μ œ μœ„μ— μ„±λ¦½ν•©λ‹ˆλ‹€.** 정적 κ²€ν†  λ²”μœ„μ—μ„œλŠ” 차단 μ‚¬μœ κ°€ μ—†μŠ΅λ‹ˆλ‹€. + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-cline-01/report-bb360685.md b/.agents/reports/reviewer-cline-01/report-bb360685.md new file mode 100644 index 0000000..c7d3993 --- /dev/null +++ b/.agents/reports/reviewer-cline-01/report-bb360685.md @@ -0,0 +1,103 @@ +# Review Report β€” Job bb360685 + +- **Reviewer**: cline (herdr session `reviewer-cline-01`, role: reviewer) +- **Job ID**: bb360685 +- **Reviewed branch**: `refactor` (changes unstaged in working tree) +- **Scope**: Cross code review (lint / operability / drift) of the diff for + "Improve 2xK grid layout engine and prevent premature workspace overflow". +- **Diff stat**: 7 files, +236 / -9 (plus a dirty submodule). + +## 1. Change Inventory + +| File | Change | Category | +|------|--------|----------| +| `.agents/skills/lib_py/layout.py` | `compute_2xk_layout` default `min_cols` 60β†’40; CLI `--min-cols` default 60β†’40; `_env_int` docstring 60β†’40 | Core logic (task goal #1, #2) | +| `.agents/skills/lib.sh:432` | Fallback `${MAM_MIN_PANE_COLS:-60}` β†’ `:-40` | Core logic (task goal #1) | +| `.mam.env.example` | Documented default `MAM_MIN_PANE_COLS` 60β†’40 | Config/docs (task goal #1) | +| `tests/test_layout.py` | Updated lib.sh snippet expectation (`:-60`β†’`:-40`); J-1 docstring 60β†’40; +4 new tests (default-40, 80-col boundary, 90/100-col tiling) | Tests (task goal #3) | +| `tests/test_tier1_unit.py` | +2 new Tier-1 tests (default-40, 90/100-col tiling) | Tests (task goal #3) | +| `tests/test_a4_adapter_contract.py` | Widened `claude` `ready_tokens` regex (`+|Claude Code|Opus|Sonnet|Haiku`) | **Unrelated to layout task** | +| `nats-docker` (submodule) | `PRIVATE_SERVER.md` modified β†’ submodule marked `-dirty` | **Stray / drift, unrelated** | + +## 2. Lint / Syntax + +- `python -m py_compile lib_py/layout.py` β†’ **OK** +- `bash -n .agents/skills/lib.sh` β†’ **OK** +- No leftover `MAM_MIN_PANE_COLS:-60` fallbacks anywhere in `.sh`/`.py`. The only + remaining `60` references are *explicit* `min_cols=60` arguments in pre-existing + layout tests (legitimate β€” they exercise the 60 configuration, not the default) + and one contrast docstring line. The default is consistently 40 across all three + authoritative sites (function signature, CLI argparse, lib.sh fallback) and the + env example. **No orphans.** + +## 3. Operability β€” Goal-by-Goal Verification + +### Goal #1 β€” Default 60β†’40 to enable 3-4 agents in ~100-col windows +- Verified all three default sites are 40 and consistent. +- Practical effect: with `min_cols=60`, a 100-col pane split right yields 50-col + halves β†’ `50 < 60` β†’ immediate `column_width_overflow` (could not even open a + 2nd column). With `min_cols=40`, `50 >= 40` β†’ 2 columns (4 panes) fit before + overflow. The change materially enables 3-4 agents per ~100-col workspace, not + merely cosmetic. βœ“ + +### Goal #2 β€” Clean 2-column split at width >= 80 without premature overflow +- Boundary predicate is `rightmost_top_pane.width // 2 < min_cols` (strict `<`). + At width 80: `80//2 = 40`, `40 < 40` is **False** β†’ splits right (not overflow). + At width 79: `79//2 = 39`, `39 < 40` is **True** β†’ `column_width_overflow`. +- CLI end-to-end confirmation (mirrors the `lib.sh` invocation path): + - 80-col payload β†’ `right p1` βœ“ + - 79-col payload β†’ `overflow p1` βœ“ +- The boundary is exactly at 80 and behaves as specified. βœ“ + +### Goal #3 β€” Updated unit tests assert default 40 & 90-100 col tiling +- New tests present in both suites: + - `test_default_min_cols_is_40` / `test_layout_default_min_cols_40_in_tier1` + - `test_80_col_2_column_splitting_boundary` + - `test_90_col_single_workspace_multi_pane_tiling` / + `test_100_col_single_workspace_multi_pane_tiling` / + `test_layout_single_workspace_90_100_cols_tiling_tier1` +- Tiling tests verify the full 1β†’2β†’3β†’4β†’(5th overflow) progression with correct + target panes and `column_width_overflow` reason. Traced the column-grouping + logic: singleton-column fill at step 3β†’4 and width-constrained overflow at + step 4β†’5 are both reached correctly. βœ“ + +### Goal #4 β€” Full pytest suite passes +- Targeted run of the three affected unit-test files: + `tests/test_layout.py tests/test_tier1_unit.py tests/test_a4_adapter_contract.py` + β†’ **99 passed in 10.97s**. βœ“ +- The complete `pytest tests/` suite could not be fully executed within this + review's time budget (integration tests are long-running), but every file + touched by the diff passes, and no unit-test regression is introduced. + +## 4. Findings (advisory, non-blocking) + +### F-1 (Hygiene/Scope): `test_a4_adapter_contract.py` change is out of scope +- The `claude` `ready_tokens` regex widening (`+|Claude Code|Opus|Sonnet|Haiku`) + is a correct, additive adapter fix and the contract test passes β€” but it is + **unrelated** to the 2xK layout-engine task. Bundling it into this diff blurs + traceability. +- **Direction**: Split into its own commit (`fix(adapter): broaden claude + ready_tokens`) before merging. No code change required for the layout work. + +### F-2 (Drift): `nats-docker` submodule is dirty +- `git diff nats-docker` shows the submodule pointer unchanged but flagged + `-dirty`; `git -C nats-docker status` shows ` M PRIVATE_SERVER.md`. +- This is a stray local modification inside the submodule, unrelated to the + task, and risks being accidentally staged/committed alongside the layout + changes. +- **Direction**: Revert the stray edit (`git -C nats-docker checkout -- + PRIVATE_SERVER.md`) or leave the submodule unstaged. Do not commit the + submodule pointer change with this work. + +## 5. Summary + +The core implementation correctly and consistently lowers the 2xK layout +`min_cols` default from 60 to 40 across `lib.sh`, `layout.py` (function + +CLI + docstring), and `.mam.env.example`, with matching, passing unit tests +covering the 80-col boundary and 90/100-col single-workspace tiling. Syntax +and CLI operability are verified. The two findings (F-1 out-of-scope adapter +regex, F-2 dirty submodule) are hygiene/drift items that do not affect the +layout engine's correctness or operability and require only commit +housekeeping, not a redesign. + +[VERDICT: PASS] \ No newline at end of file diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 880af2b..11fe96c 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -429,7 +429,7 @@ except Exception: split_dir="" if [ -n "$sample_pane" ]; then layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "") - read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-60}" --min-rows "${MAM_MIN_PANE_ROWS:-20}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane") + read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-40}" --min-rows "${MAM_MIN_PANE_ROWS:-20}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane") split_dir="${split_dir:-right}" sample_pane="${split_target:-$sample_pane}" fi diff --git a/.agents/skills/lib_py/layout.py b/.agents/skills/lib_py/layout.py index b1c9d86..b73445c 100644 --- a/.agents/skills/lib_py/layout.py +++ b/.agents/skills/lib_py/layout.py @@ -70,7 +70,7 @@ def extract_panes(data: Dict[str, Any]) -> List[PaneInfo]: def compute_2xk_layout( data: Dict[str, Any], - min_cols: int = 60, + min_cols: int = 40, min_rows: int = 20, max_columns: Optional[int] = None, default_anchor_id: Optional[str] = None @@ -176,7 +176,7 @@ def _env_int(*names: str, default: Optional[int] = None) -> Optional[int]: """First *valid* int among the env vars in *names*, else `default`. `default` is an explicit parameter rather than an `or` at the call site so a - legitimate 0 survives (MAM_MIN_PANE_COLS=0 means 0, not the 60 default). + legitimate 0 survives (MAM_MIN_PANE_COLS=0 means 0, not the 40 default). An unparsable value is skipped rather than raised or treated as terminal: a typo in an operator's shell must not take the whole layout call down (lib.sh @@ -198,7 +198,7 @@ def _env_int(*names: str, default: Optional[int] = None) -> Optional[int]: def main(): parser = argparse.ArgumentParser(description="Compute 2xK grid TUI layout split direction") - parser.add_argument("--min-cols", type=int, default=_env_int("MAM_MIN_COLS", "MAM_MIN_PANE_COLS", default=60)) + parser.add_argument("--min-cols", type=int, default=_env_int("MAM_MIN_COLS", "MAM_MIN_PANE_COLS", default=40)) parser.add_argument("--min-rows", type=int, default=_env_int("MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS", default=20)) parser.add_argument("--max-cols", type=int, default=_env_int("MAM_MAX_COLS", "MAM_MAX_PANE_COLS")) parser.add_argument("--sample-pane", type=str, default=None) diff --git a/.mam.env.example b/.mam.env.example index d1d346e..40d486d 100644 --- a/.mam.env.example +++ b/.mam.env.example @@ -129,8 +129,8 @@ # SKS_EMPTY_GIVEUP=3 # Minimum columns a pane must retain after a vertical split (2xK layout engine). -#default: 60 -# MAM_MIN_PANE_COLS=60 +#default: 40 +# MAM_MIN_PANE_COLS=40 # Minimum rows a pane must retain after a horizontal split (2xK layout engine). #default: 20 diff --git a/tests/test_a4_adapter_contract.py b/tests/test_a4_adapter_contract.py index 7e39504..d5e09de 100644 --- a/tests/test_a4_adapter_contract.py +++ b/tests/test_a4_adapter_contract.py @@ -63,7 +63,7 @@ def test_adapter_required_properties(): getattr(base, prop) expected = { - 'claude': ('Anthropic|Assistant|Chat|Welcome', '/exit', 'claude-code', ('session_id', 'session_jsonl', 'session_size_bytes', 'session_lines')), + 'claude': ('Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku', '/exit', 'claude-code', ('session_id', 'session_jsonl', 'session_size_bytes', 'session_lines')), 'agy': ('Antigravity', 'Exit', 'antigravity-cli', ('conversation_id', 'conversation_db', 'conversation_brain_dir')), 'hermes': ('Hermes', '/exit', 'hermes-agent', ('session_id',)), 'cline': ('Cline|history|Chat|What can I do|slash commands', '/exit', 'cline-agent', ('session_id',)), diff --git a/tests/test_layout.py b/tests/test_layout.py index f686a23..218fafc 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -287,7 +287,7 @@ split_dir="" # Exact snippet from lib.sh:429-435 if [ -n "$sample_pane" ]; then layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "") - read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${{MAM_MIN_PANE_COLS:-60}}" --min-rows "${{MAM_MIN_PANE_ROWS:-20}}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane") + read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${{MAM_MIN_PANE_COLS:-40}}" --min-rows "${{MAM_MIN_PANE_ROWS:-20}}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane") split_dir="${{split_dir:-right}}" sample_pane="${{split_target:-$sample_pane}}" fi @@ -435,7 +435,7 @@ _ZERO_TRAP = {"result": {"panes": [ def test_j1_env_zero_min_cols_matches_flag_zero(): - """J-1: MAM_MIN_PANE_COLS=0 must mean 0, not fall through to the 60 default.""" + """J-1: MAM_MIN_PANE_COLS=0 must mean 0, not fall through to the 40 default.""" flag = _run_layout(_ZERO_TRAP, ("--min-cols", "0")) assert flag["direction"] == "right" and flag["reason"] == "single_pane_height_constrained" for var in ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS"): @@ -479,3 +479,144 @@ def test_j1b_invalid_alias_does_not_shadow_the_documented_var(): "MAM_MIN_PANE_COLS": "bar"}) == _run_layout(_ZERO_TRAP) +def test_default_min_cols_is_40(): + """Verify compute_2xk_layout default min_cols is 40. + With width 80 (width//2 = 40): + - min_cols=40 -> 40 >= 40 -> split right (new column). + - min_cols=60 -> 40 < 60 -> overflow. + Default invocation (no min_cols passed) must split right. + """ + payload = { + "result": { + "panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 40}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 80, "height": 40}}, + ] + } + } + decision = compute_2xk_layout(payload) + assert decision.direction == "right" + assert not decision.is_overflow + assert decision.reason == "new_column_right" + + +def test_80_col_2_column_splitting_boundary(): + """Verify width >= 80 cols allows 2-column splitting with default min_cols=40, + while width < 80 (e.g. 79) triggers column_width_overflow. + """ + # 80 cols: 80 // 2 = 40 == min_cols(40) -> splits right + payload_80 = { + "result": { + "panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 40}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 80, "height": 40}}, + ] + } + } + d80 = compute_2xk_layout(payload_80) + assert d80.direction == "right" + assert not d80.is_overflow + + # 79 cols: 79 // 2 = 39 < min_cols(40) -> overflow + payload_79 = { + "result": { + "panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 79, "height": 40}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 79, "height": 40}}, + ] + } + } + d79 = compute_2xk_layout(payload_79) + assert d79.direction == "overflow" + assert d79.is_overflow + assert d79.reason == "column_width_overflow" + + +def test_90_col_single_workspace_multi_pane_tiling(): + """Verify complete 1 -> 2 -> 3 -> 4 pane tiling in a 90-col single workspace. + - 1 pane (90x40): splits down to p1(90x20), p2(90x20) + - 2 panes: splits right to start col 2 -> p3(45x40) + - 3 panes: fills singleton col 2 down -> p4(45x20) + - 4 panes (2x2 grid): 5th agent overflows because 45 // 2 = 22 < 40 + """ + # 1 -> 2 + p1_layout = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 90, "height": 40}}]}} + d1 = compute_2xk_layout(p1_layout) + assert d1.direction == "down" + assert d1.target_pane_id == "p1" + + # 2 -> 3 + p2_layout = {"result": {"panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 90, "height": 20}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 90, "height": 20}}, + ]}} + d2 = compute_2xk_layout(p2_layout) + assert d2.direction == "right" + assert d2.target_pane_id == "p1" + assert not d2.is_overflow + + # 3 -> 4 + p3_layout = {"result": {"panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 45, "height": 20}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 45, "height": 20}}, + {"pane_id": "p3", "rect": {"x": 45, "y": 0, "width": 45, "height": 40}}, + ]}} + d3 = compute_2xk_layout(p3_layout) + assert d3.direction == "down" + assert d3.target_pane_id == "p3" + assert not d3.is_overflow + + # 4 -> 5 (overflow to new workspace) + p4_layout = {"result": {"panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 45, "height": 20}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 45, "height": 20}}, + {"pane_id": "p3", "rect": {"x": 45, "y": 0, "width": 45, "height": 20}}, + {"pane_id": "p4", "rect": {"x": 45, "y": 20, "width": 45, "height": 20}}, + ]}} + d4 = compute_2xk_layout(p4_layout) + assert d4.direction == "overflow" + assert d4.is_overflow + assert d4.reason == "column_width_overflow" + + +def test_100_col_single_workspace_multi_pane_tiling(): + """Verify complete 1 -> 2 -> 3 -> 4 pane tiling in a 100-col single workspace.""" + # 1 -> 2 + p1_layout = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 100, "height": 40}}]}} + d1 = compute_2xk_layout(p1_layout) + assert d1.direction == "down" + + # 2 -> 3 + p2_layout = {"result": {"panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 100, "height": 20}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 100, "height": 20}}, + ]}} + d2 = compute_2xk_layout(p2_layout) + assert d2.direction == "right" + assert not d2.is_overflow + + # 3 -> 4 + p3_layout = {"result": {"panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 20}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 50, "height": 20}}, + {"pane_id": "p3", "rect": {"x": 50, "y": 0, "width": 50, "height": 40}}, + ]}} + d3 = compute_2xk_layout(p3_layout) + assert d3.direction == "down" + assert d3.target_pane_id == "p3" + assert not d3.is_overflow + + # 4 -> 5 (overflow to new workspace) + p4_layout = {"result": {"panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 20}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 50, "height": 20}}, + {"pane_id": "p3", "rect": {"x": 50, "y": 0, "width": 50, "height": 20}}, + {"pane_id": "p4", "rect": {"x": 50, "y": 20, "width": 50, "height": 20}}, + ]}} + d4 = compute_2xk_layout(p4_layout) + assert d4.direction == "overflow" + assert d4.is_overflow + assert d4.reason == "column_width_overflow" + + + diff --git a/tests/test_tier1_unit.py b/tests/test_tier1_unit.py index ae28fe7..8ed5c20 100644 --- a/tests/test_tier1_unit.py +++ b/tests/test_tier1_unit.py @@ -864,5 +864,91 @@ def test_lib_sh_new_session_passes_mam_ws_label(mam_sandbox): assert '_real_herdr workspace rename "$existing_ws" "$MAM_WS_LABEL"' in content +# ============================================================================== +# FEATURE: 2xK Grid Layout Engine (min_cols=40 & multi-pane workspace tiling) +# ============================================================================== + +def test_layout_default_min_cols_40_in_tier1(): + """Verify default min_cols=40 behavior across compute_2xk_layout in Tier 1 suite.""" + from lib_py.layout import compute_2xk_layout + + # 1. 2 panes in 80 col width (80 // 2 = 40 == min_cols 40) -> splits right cleanly + payload_80 = { + "result": { + "panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 40}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 80, "height": 40}} + ] + } + } + decision = compute_2xk_layout(payload_80) + assert decision.direction == "right" + assert not decision.is_overflow + assert decision.reason == "new_column_right" + + # 2. 2 panes in 79 col width (79 // 2 = 39 < min_cols 40) -> column_width_overflow + payload_79 = { + "result": { + "panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 79, "height": 40}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 79, "height": 40}} + ] + } + } + decision_overflow = compute_2xk_layout(payload_79) + assert decision_overflow.direction == "overflow" + assert decision_overflow.is_overflow + assert decision_overflow.reason == "column_width_overflow" + + +def test_layout_single_workspace_90_100_cols_tiling_tier1(): + """Verify 3-4 agents tiling in standard 90-100 col terminal windows within a single workspace.""" + from lib_py.layout import compute_2xk_layout + + for total_w in [90, 100]: + half_w = total_w // 2 + + # Step 1: 1 pane -> 2 panes (split down) + p1 = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": total_w, "height": 40}}]}} + d1 = compute_2xk_layout(p1) + assert d1.direction == "down" + assert d1.target_pane_id == "p1" + assert not d1.is_overflow + + # Step 2: 2 panes -> 3 panes (split right to open 2nd column) + p2 = {"result": {"panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": total_w, "height": 20}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": total_w, "height": 20}} + ]}} + d2 = compute_2xk_layout(p2) + assert d2.direction == "right" + assert d2.target_pane_id == "p1" + assert not d2.is_overflow + + # Step 3: 3 panes -> 4 panes (split singleton 2nd column down) + p3 = {"result": {"panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": half_w, "height": 20}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": half_w, "height": 20}}, + {"pane_id": "p3", "rect": {"x": half_w, "y": 0, "width": half_w, "height": 40}} + ]}} + d3 = compute_2xk_layout(p3) + assert d3.direction == "down" + assert d3.target_pane_id == "p3" + assert not d3.is_overflow + + # Step 4: 4 panes (2x2 complete) -> 5th agent overflows to fresh workspace + p4 = {"result": {"panes": [ + {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": half_w, "height": 20}}, + {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": half_w, "height": 20}}, + {"pane_id": "p3", "rect": {"x": half_w, "y": 0, "width": half_w, "height": 20}}, + {"pane_id": "p4", "rect": {"x": half_w, "y": 20, "width": half_w, "height": 20}} + ]}} + d4 = compute_2xk_layout(p4) + assert d4.direction == "overflow" + assert d4.is_overflow + assert d4.reason == "column_width_overflow" + + +