# lib.sh 인라인 파이썬 분리 여부 — 손익 분석 및 최종 결정 (Rev.2) - **job_id**: `98393a97` (Rev.1 = `6481e5b4`) - **역할**: Planner - **반영한 이의제기**: `f3a0adf1` (agy, `herdr:agy-creator-01`) — `[VERDICT: PASS WITH CHALLENGE]` - **실측 하네스**: - `.mam/jobs/6481e5b4/claude-reports/proposed/probe_inline_vs_module.sh` (Rev.1, 유효) - `.mam/jobs/98393a97/claude-reports/proposed/probe_verify_py_coupling.sh` (Rev.2 신규, 실행·검증 완료) - **저장소 변경**: 없음 --- ## 0. Rev.1 대비 변경 요약 **agy 의 3개 주장이 전부 사실로 확인되었다. 반박할 것이 없다.** Rev.1 의 §5 D2 는 `VERIFY_SESSION_PYTHON` 을 두고 *"`verify_session_uuid` 라는 단일 진입점만 갖는다"* 고 썼다. **이것이 틀렸다.** 소비자는 3곳이며, 그중 하나는 `lib.sh` 밖의 장기 실행 모니터다. 나는 `lib.sh` 안에서만 소비자를 찾았고 저장소 전체를 검색하지 않았다. | 이의제기 항목 | 판정 | Rev.2 반영 | |---|---|---| | §1 `reconcile.sh` 의 `MAM_VERIFY_PY` 런타임 붕괴 | **[ADJUDICATION: SUSTAINED]** | D2 를 D2a/D2b 로 분할, 파사드 착지 방식 도입 | | §2.1 `_validate_env_key` 의 `PYTHONPATH` 차단 | **[ADJUDICATION: SUSTAINED]** | D0 을 내부 결합으로 한정 + **선행조건에서 심층방어로 강등** | | §2.2 `find_workspace_uuid` 의 `MAM_VERIFY_PY` 결합 | **[ADJUDICATION: SUSTAINED]** | D4 에 명시적 의존 해소 단계 추가 | **결정 자체(부분 분리, 대형 블록 3개)는 바뀌지 않는다.** 바뀌는 것은 *어떻게 착지시키는가* 이며, 그것이 이 이의제기의 기여다. 부수적으로 agy 의 서술 1건을 정정한다(§1.2) — 결론에는 영향이 없다. --- ## 1. 이의제기 판정 ### 1.1 Primary — `reconcile.sh` 의 `MAM_VERIFY_PY` 결합: **SUSTAINED** 저장소 전체를 검색해 소비자를 전수 확인했다: ``` reconcile.sh:323 exec(os.environ['MAM_VERIFY_PY']) reconcile.sh:862 … MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML" reconcile.sh:864 … MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" atomic_dump_yaml "$AGENT_SESSIONS_YAML" lib.sh:1329 VERIFY_SESSION_PYTHON=' lib.sh:1546/1548 verify_session_uuid — 주입 + exec lib.sh:1618/1627 find_workspace_uuid — 주입 + exec ``` **소비자는 3곳이다**: `verify_session_uuid`(lib.sh), `find_workspace_uuid`(lib.sh), 그리고 `reconcile.sh`. Rev.1 이 "단일 진입점"이라고 한 것은 세 번째를 놓친 결과다. `reconcile.sh` 는 `lib.sh` 를 source 한 뒤 셸 변수 `$VERIFY_SESSION_PYTHON` 을 자기 파이썬 heredoc 에 환경변수로 실어 보내고, 그 안에서 `exec()` 로 네임스페이스에 푼다. **셸 변수를 없애면 `MAM_VERIFY_PY` 가 빈 문자열이 되고 `exec("")` 는 아무것도 정의하지 않으며, 이후 첫 호출에서 `NameError` 가 난다.** agy 가 서술한 메커니즘 그대로다. 이것이 특히 위험한 이유는 `reconcile.sh` 가 **백그라운드 모니터 루프**라는 점이다. 대화형 명령이 아니라 watchdog 으로 돌기 때문에, 실패가 사용자 눈앞이 아니라 로그 안에서 조용히 일어난다. ### 1.2 정정: agy 가 사용 함수를 하나 더 셌다 agy 는 `reconcile.sh` 가 `verify_session_uuid()`, `workspace_key()`, `mam_orchestrator_uuids()` 를 쓴다고 했다. 실측: ``` VERIFY_SESSION_PYTHON 본문: 213 줄, 정의 함수 4개 mam_orchestrator_uuids reconcile.sh 참조 0회 mam_row_own_uuid reconcile.sh 참조 0회 workspace_key reconcile.sh 참조 1회 <- 사용 verify_session_uuid reconcile.sh 참조 6회 <- 사용 ``` `mam_orchestrator_uuids` 는 `reconcile.sh` 에서 **한 번도 참조되지 않는다.** 실제 결합은 4개 중 2개다. 이는 결론을 바꾸지 않지만(2개든 3개든 제거하면 깨진다), D2b 에서 import 로 전환할 때 **필요한 이름이 2개뿐**이라는 사실은 작업량 산정에 쓸모가 있다. ### 1.3 `_validate_env_key` 의 `PYTHONPATH` 차단: **SUSTAINED** — 그리고 이것이 D0 을 재검토하게 만들었다 가드는 실재하고 실제로 동작한다: ``` lib.sh: LD_PRELOAD|LD_LIBRARY_PATH|PYTHONPATH|PYTHONHOME|PYTHONINSPECT|PYTHONSTARTUP) echo "ERROR: Blocked environment variable: $key" >&2; return 1 실측: env_python /dev/null PYTHONPATH=/tmp/x → ERROR: Blocked environment variable: PYTHONPATH ``` agy 가 지적한 대로 **D0 을 "인자 전달"로 구현하면 즉시 막힌다.** 그리고 agy 가 권고한 "내부 결합" 방식은 성립한다 — `env_python` 의 초기 `envs` 배열은 직접 구성되며 `_validate_env_key` 를 거치지 않기 때문이다. 이 방식은 가드를 **우회하지도 약화시키지도 않는다**: 가드의 목적은 *외부 호출자*가 인터프리터 탐색 경로를 조작하는 것을 막는 것이고, 프레임워크가 자기 자신의 패키지 경로를 넣는 것은 그 위협 모델 밖이다. **그런데 이 지적을 확인하다가 Rev.1 의 더 큰 오류를 발견했다.** Rev.1 §3.2 는 D0 을 *"분리의 필수 동반 조건"* 이라고 단정했다. 검증해 보니 사실이 아니다: ``` child PYTHONPATH = /Users/…/multi-agent-mux/.agents/skills… atomic_dump_yaml 의 env 호출에 -i 가 있는가: 없음 → 상속됨 ``` `env` 를 `-i` 없이 쓰므로 부모 환경이 그대로 상속되고, `lib.sh:25` 의 `export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}"` 가 이미 자식에 도달한다. Rev.1 이 근거로 든 실패 사례 (b)("호출자가 `PYTHONPATH` 를 지운 뒤")는 **내가 만든 인위적 조건**이었고, 실제 코드 경로에는 그런 호출자가 없다. **따라서 D0 은 선행 조건이 아니라 심층 방어다.** 등급을 낮추고 순서에서 앞으로 끌어낼 이유도 없앤다. 이 정정은 agy 의 §2.1 이 아니었으면 하지 못했을 것이다. ### 1.4 `find_workspace_uuid` 의 결합: **SUSTAINED** `lib.sh:1618/1627` 에서 동일 패턴이 확인된다. D4 가 `find_workspace_uuid` 본문을 옮길 때 `exec(os.environ['MAM_VERIFY_PY'])` 를 `from lib_py.verify_session import verify_session_uuid, workspace_key` 로 바꾸지 않으면 같은 `NameError` 가 난다. **D2 와 D4 는 독립 단계가 아니라 같은 결합을 공유한다.** --- ## 2. 해법: 파사드로 착지시킨다 (실측 검증됨) agy 는 두 선택지를 제시했다 — (1) `lib.sh` 에 하위 호환 파사드를 남기거나, (2) `reconcile.sh` 를 import 방식으로 고치거나. **두 개를 순서대로 다 한다.** 그래야 이동과 소비자 수정이 분리된다. ### 2.1 파사드안 A: `VERIFY_SESSION_PYTHON="$(cat lib_py/verify_session.py)"` 셸 변수를 **없애지 않고**, 그 내용을 파일에서 읽어 채운다. 현재 213줄을 그대로 `.py` 로 추출해 실측했다: ``` (a) exec 계약 보존 : OK — 필요한 이름 전부 로드 (b) workspace_key 동작 : workspace_key(/a/b_c) = -a-b-c (c) 작은따옴표 허용 : apostrophes fine ``` 세 결과가 뜻하는 바: - **(a)(b)**: 세 소비자 전부 코드 한 줄도 고치지 않는다. `reconcile.sh` 는 자기가 파사드를 보고 있다는 사실조차 모른다. `NameError` 위험이 원천적으로 없다. - **(c)** 가 핵심이다. 현행 `VERIFY_SESSION_PYTHON='…'` 는 홑따옴표 문자열이라 본문에 `'` 를 넣을 수 없다(Rev.1 §2.3). 파사드는 `$(cat …)` 명령치환이므로 **그 제약이 사라진다.** 실험에서 `# it's a comment — don't break` 를 파일에 넣고도 `exec` 가 정상 동작했다. 즉 **파사드만으로도 분리 이득의 상당 부분이 즉시 실현된다**: 정적 검사 대상이 되고(D5), 따옴표 제약이 없어지고, 파일로서 편집·리뷰·diff 가 가능해진다. 잃는 것은 `exec` 를 거치므로 트레이스백이 여전히 `` 이라는 점뿐이다. ### 2.2 그다음에 소비자를 하나씩 import 로 전환 파사드가 자리잡은 뒤, 소비자 3곳을 **각각 독립적으로** `from lib_py.verify_session import verify_session_uuid, workspace_key` 로 바꾼다. `reconcile.sh` 는 이름 2개만 필요하다(§1.2). 마지막 소비자가 전환되면 파사드를 제거한다. 이 순서의 이점은 **어느 단계에서 멈춰도 시스템이 동작한다**는 것이다. 파사드까지만 하고 멈춰도 되고, 소비자 1개만 전환하고 멈춰도 된다. --- ## 3. 결정 (Rev.1 에서 변경 없음) **부분 분리한다. 전면 분리도, 현행 유지도 아니다.** > 736줄의 이동 가능한 파이썬 중 **대형 블록 3개(635줄)만** `lib_py/` 로 분리한다. > 소형 4개(101줄)와 shim 679줄 + 인라인 12개는 **그대로 둔다.** | | 이동 | 줄수 | 이유 | |---|---|---|---| | `atomic_dump_yaml` 본문 | **O** | 213 | 최대 블록, 동시성·원자성 로직 | | `VERIFY_SESSION_PYTHON` | **O** | 213 | 따옴표 제약의 유일한 피해자 — 단 **소비자 3곳**(§1.1) | | `find_workspace_uuid` 본문 | **O** | 209 | P0-C 불변식 구현부 — 같은 결합 공유(§1.4) | | 소형 PYEOF 4개 | **X** | 101 | 파일 4개를 늘려도 얻는 것이 없음 | | shim + 인라인 12개 | **X** | 679+ | `PYTHONPATH` 없이 도는 독립 스크립트 (측정된 제약) | 근거(Rev.1 §1–§4)는 이의제기의 영향을 받지 않았다. 요약하면: - **현행 비용**: CI 의 flake8/py_compile 이 `lib.sh` 를 제외하므로 736줄이 정적 검사 사각지대다. 주입한 `SyntaxError` 는 `bash -n` 도 `source` 도 통과하고, 해당 함수 호출 시점에야 `File "", line 3` 으로 나타난다. 213줄이 작은따옴표를 쓸 수 없다. - **분리 비용**: 배포는 `install.sh` 의 `find . -type f` + `is_framework_owned` 글롭이 자동 처리하므로 사실상 0(`remove.sh` 1줄). 신규 실패 모드는 `PYTHONPATH` 의존 1건이며, §1.3 에서 확인했듯 기본 경로에서는 이미 상속으로 해결되어 있다. --- ## 4. 실행 계획 (Rev.2) **D0. `env_python` 의 `PYTHONPATH` 내부 결합** — *심층 방어, 선행 조건 아님* ← §1.3 반영 `envs` 초기 배열에 `"PYTHONPATH=$SKILL_DIR:${PYTHONPATH:-}"` 를 **덧붙이는** 형태로 추가한다. **인자로 전달하지 않는다** — `_validate_env_key` 가 막는다. 기존 상속 경로가 이미 동작하므로 **이 단계는 생략해도 D1–D6 이 성립한다.** 우선순위 최하. **D1. `lib_py/` 패키지 골격 + `remove.sh` fallback 1줄 추가** **D2a. `VERIFY_SESSION_PYTHON` → `lib_py/verify_session.py` + 파사드 착지** ← §2.1 `lib.sh` 에서 213줄을 파일로 옮기고, 셸 변수는 `VERIFY_SESSION_PYTHON="$(cat "$SKILL_DIR/lib_py/verify_session.py")"` 로 대체한다. **소비자 3곳을 전혀 건드리지 않는다.** 이 시점에 따옴표 제약이 사라지고 정적 검사가 가능해진다. *검증*: `reconcile.sh --once` 를 실제로 돌려 `NameError` 가 없고 drift 판정 결과가 이전과 동일한지 확인한다. **이 단계의 회귀 테스트는 `reconcile.sh` 실행이지 `lib.sh` 단위 테스트가 아니다.** **D2b. 소비자 3곳을 import 로 전환 (각각 독립)** ← §2.2 ① `verify_session_uuid`(lib.sh:1546) → ② `find_workspace_uuid`(lib.sh:1618) → ③ `reconcile.sh`(862/864/323). ③ 은 이름 2개(`verify_session_uuid`, `workspace_key`)만 필요하다. 세 번째가 끝나면 파사드를 제거한다. **각 전환은 독립 커밋으로.** **D3. `atomic_dump_yaml` 본문 → `lib_py/atomic_yaml.py`** — `-m` 실행으로 stdin 을 해방하고 `AGENT_SESSIONS_MUTATION` exec 계약을 보존한다(`7132d954` 스파이크 (f) 검증됨). **주의**: `reconcile.sh:864` 가 `atomic_dump_yaml` 을 `MAM_VERIFY_PY` 와 함께 호출하므로, D3 은 D2b-③ 이후에 하거나 파사드가 살아 있는 동안 해야 한다. **D4. `find_workspace_uuid` 본문 → `lib_py/workspace_uuid.py`** — D2b-② 를 포함한다(같은 결합, §1.4). **D5. CI 에 `lib_py/` 를 flake8 / py_compile 경로로 추가** — §2.1 의 이득을 실현하는 지점. **빠뜨리면 분리의 최대 명분이 사라진다.** D2a 직후에 해도 된다(파사드 상태에서도 `.py` 파일은 검사 가능). **D6. 소형 블록 4개는 명시적으로 유지하고 이유를 주석으로 남긴다.** **검증 원칙**: 각 단계마다 HEAD 구현과 새 구현에 동일 입력을 주고 stdout·종료코드·YAML/DB 산출물을 바이트 비교한다. **추가로, `MAM_VERIFY_PY` 를 건드리는 모든 단계(D2a/D2b/D3/D4)는 `reconcile.sh --once` 실행을 회귀 게이트에 포함한다** — 이의제기가 지적한 붕괴는 `lib.sh` 단위 테스트로는 잡히지 않는다. --- ## 5. 다른 작업과의 순서 1. **`3aee63cf` Rev.2 Phase 1 (W1–W5)** — P0 2건. `lib_py` 에 의존하지 않으므로 **가장 먼저.** 2. **D1 → D2a → D5** — 파사드까지. 소비자 무수정. 3. **D2b → D3 → D4** — import 전환 및 나머지 블록. 4. **`3aee63cf` Rev.2 Phase 2 이후** — 에이전트 레지스트리(W6a)가 `lib_py` 위에 올라간다. 5. **D0** — 아무 때나. 다른 무엇의 선행 조건도 아니다. **전 보고서 작성 시점에 Creator 가 이미 1번에 착수했고**, 워킹트리의 `lib.sh` 차이에 W2(hermes `SELECT cwd FROM sessions`)가 들어 있었다. D2a 가 옮기려는 213줄이 그 편집 대상이므로, **Phase 1 이 커밋으로 확정된 뒤 D1 을 시작해야 한다.** --- ## 6. 리스크 및 미측정 항목 **6.1 `reconcile.sh` 의 나머지 결합은 전수 확인하지 않았다.** `MAM_VERIFY_PY` 는 저장소 전체 grep 으로 전수 확인했으나(§1.1), `reconcile.sh` 가 `lib.sh` 의 *다른* 셸 변수나 함수에 유사하게 의존하는지는 조사하지 않았다. **D2b-③ 착수 전 `reconcile.sh` 840줄의 `lib.sh` 의존 전수 조사를 별도로 수행할 것.** 이번 이의제기가 정확히 그 종류의 누락에서 나왔다. **6.2 §2.1 의 파사드 실험은 현재 213줄 본문으로 했다.** Creator 가 진행 중인 W1/W2 가 이 본문을 수정 중이므로, 확정 후 파사드 실험을 한 번 더 돌려야 한다. 하네스가 `lib.sh` 에서 본문을 직접 추출하므로 재실행만 하면 된다. **6.3 `$(cat …)` 파사드는 파일이 없을 때 조용히 빈 문자열이 된다.** 이는 `MAM_VERIFY_PY=""` → `exec("")` → `NameError` 라는 **이의제기가 지적한 바로 그 실패 모드**와 같다. D2a 는 파사드에 존재 검사를 반드시 포함해야 한다 — 파일이 없으면 명시적으로 실패해야지, 빈 문자열로 넘어가면 안 된다. **6.4 `lib_py` 는 PyYAML 외의 서드파티 의존을 추가해서는 안 된다.** 가용성은 시스템 python3(6.0.3)와 `.venv`(6.0.3)에서 확인했으나 이 머신 한정이다. **6.5 `shellcheck` 가 인용 heredoc 본문을 검사하지 않는다는 것은 직접 실행으로 확인하지 못했다** — 로컬 미설치. shellcheck 의 문서화된 동작에 근거한 판단이며, CI 경로 배제는 설정 파일에서 직접 확인한 사실이므로 결론은 유지된다. **6.6 Rev.1 의 측정 기준은 커밋되지 않은 워킹트리였다** (HEAD 2275 vs 워킹트리 2284, 6줄 차이). 대형 블록 3개의 식별과 200줄 임계 기준은 양쪽에서 동일하다. --- ## 7. 판정 이의제기 3건이 전부 사실로 확인되어 **모두 수용**했다. 반박한 항목은 없고, 서술 1건(`mam_orchestrator_uuids` 사용 여부)만 정정했다. 이의제기를 검증하는 과정에서 **Rev.1 자신의 오류 2건**을 함께 발견했다 — D2 의 "단일 진입점" 서술(§1.1)과 D0 의 "필수 선행 조건" 등급(§1.3). **[ADJUDICATION: SUSTAINED]** — `f3a0adf1` §1 (`reconcile.sh` 결합), §2.1 (`_validate_env_key`), §2.2 (`find_workspace_uuid` 결합) **결정: 부분 분리 유지 — 대형 블록 3개(635줄) 이동, 단 파사드로 착지시킨 뒤 소비자를 하나씩 전환.** **[VERDICT: PASS]** *(이 토큰은 이 결정 보고서 산출물의 완성도를 뜻한다. 감사 대상 코드에 대한 판정이 아니다.)*