# 44062a63 — `BaseAgentAdapter` 아키텍처 설계 **Rev.2** **Job**: 44062a63 · **Role**: Planner · **Supersedes**: 744ac67a (Rev.1) **응답 대상**: 챌린지 `c52bb834` (`agy`, `[CHALLENGE: RAISED]`) **Base**: `245abe6` --- ## 1. 판정 요약 **본 이의 1건과 보충 제언 2건 모두 채택한다.** 그리고 셋 중 둘은 agy 가 말한 것보다 **나쁘다**. | # | 항목 | 판정 | 실측 | |---|---|---|---| | C-1 | `candidate_uuids` 서명에 `cwd`/`epoch`/`claimed_uuids` 누락 | **채택 — 증상은 예측보다 위험** | agy 는 `[]` 를 예측했으나 실제는 **타 워크스페이스 대화가 유효 후보로 반환**된다 | | M-1 | `PYTHONPATH` 부트스트랩 부족 | **우려 채택 · 제안 기각** | `run_loop.sh` 의 맨 `python3 -c` 5곳에서 `ModuleNotFoundError` 재현. 단 제안한 `__init__.py` 내 `sys.path.insert` 는 **순환이라 실행 자체가 불가능** | | M-2 | `ready_tokens` 어댑터 이관 | **채택 — 효과가 예측보다 큼** | "2~3곳"이 아니라 `wait_for_tui_ready` 의 **25줄 case 블록 하나**가 데이터 조회 1줄로 바뀐다 | 정정부터. Rev.1 §4.2 의 `candidate_uuids(ws_key, home, claude_dir, iso_root="")` 는 내가 claude 의 디렉터리 구조만 보고 서명을 뽑은 결과다. agy·hermes·cline 은 **절대경로 `cwd`** 로 스코프하는데 그 인자가 아예 없었다. agy 가 정확히 짚었다. **측정 결과: Rev.2 어댑터 4종 전부 `discover()` 정확. 변이 6건 전부 검출. 전체 회귀 162 passed, 0건.** --- ## 2. C-1 — 채택. 다만 실패 양상이 예측과 다르다 ### 2.1 agy 의 예측 vs 실제 agy 는 "`lc_data.get(ws_key)` 조회 실패 → 항상 `[]` 반환"이라고 봤다. 그런데 Rev.1 프로토타입의 agy 어댑터는 `last_conversations.json` 을 **아예 보지 않는다.** conversations 디렉터리를 통째로 glob 한다. 실행해 봤다: ``` agy .candidate_uuids -> ['agy-mine', 'agy-foreign'] hermes.candidate_uuids -> ['herm-mine', 'herm-foreign', 'herm-ancient'] 기대: agy -> ['agy-mine'] (agy-foreign 은 /work/other 소속) hermes -> ['herm-mine'] (herm-foreign 은 타 cwd, herm-ancient 는 세션 생성 이전) ``` `[]` 는 **서비스 거부**다. 지금 나오는 값은 **격리 위반**이다. 후자가 훨씬 나쁘다. b4a1d094 이후 이 저장소가 계속 방어해 온 바로 그 부류의 결함이다. ### 2.2 `verify_artifact` 도 막아 주지 않는다 `len(valid_candidates)==1` 게이트가 걸러 줄 거라 기대할 수도 있지만, 검증 단계를 실측했다: ``` agy agy-foreign -> True hermes herm-foreign -> True hermes herm-ancient -> True ``` 전부 통과한다. 그러면 두 결말뿐이다 — 후보가 1개면 **남의 대화를 고정**하고, 2개면 b107cf34 에서 없앤 **영구 교착**으로 되돌아간다. 둘 다 받아들일 수 없다. 여기서 agy 가 언급하지 않은 두 번째 결함이 나온다. **`verify_artifact(path, uuid, cwd)` 는 `cwd` 를 이미 인자로 받고 있는데 agy·hermes 분기가 그걸 쓰지 않는다.** C-1 은 발견 단계만 지적했지만 검증 단계도 같은 병을 앓고 있었다. ### 2.3 hermes 는 파일 mtime 으로 epoch 을 걸 수 없다 agy 의 권고안은 `epoch` 을 인자로 넘기라고만 한다. 그런데 hermes 는 **모든 세션이 하나의 `state.db` 를 공유**한다. 파일 mtime 은 후보 전체에 대해 같은 값이므로 mtime 기반 필터는 "전부 통과" 아니면 "전부 탈락" 두 가지 답만 낼 수 있다. hermes 는 `sessions.started_at` 을 갖고 있으므로 그걸 써야 한다. 이건 **어댑터별 오버라이드 지점**이고, 평평한 인자 목록만으로는 드러나지 않는다. > **미검증 항목**: hermes 는 이 머신에 설치돼 있지 않다(`command not found`). > `sessions(id, cwd, started_at)` 스키마는 `lib.sh:1454` 의 실제 쿼리와 `tests/conftest.py` > 의 mock 정의에서 역산한 것이다. 실 CLI 대조는 구현자 몫이다. --- ## 3. C-1 설계 — 평평한 인자 대신 컨텍스트 객체 agy 의 권고안은 인자 7개짜리 서명이다. 방향은 맞지만 형태를 바꾼다. ```python @dataclass(frozen=True) class DiscoveryContext: cwd: str # /Users/x/proj -- agy, hermes, cline ws_key: str = "" # -Users-x-proj -- claude home: str = "" claude_dir: str = "" iso_root: str = "" epoch: float = 0.0 # 0 이면 필터 비활성 claimed: frozenset = frozenset() ``` **이유**: 이 서명은 **두 번의 리뷰에서 두 번 바뀌었다**(Rev.1 → `cwd` 추가 → `epoch`/`claimed` 추가). 위치 인자 목록은 바뀔 때마다 어댑터 4개 + 모든 호출부를 함께 고쳐야 한다. 세 번째 변경이 없으리라 가정할 근거가 없다. `cwd` 와 `ws_key` 를 **둘 다** 담는 것이 핵심이다. 둘은 교환 가능하지 않다 — claude 는 `ws_key` 로 디렉터리를 찾고, agy(`last_conversations.json`)·hermes(`sessions.cwd`)· cline(세션 json 의 `cwd`)은 절대경로로 찾는다. 하나만 넘기면 어느 쪽이든 반이 깨진다. ### 3.1 필터는 어댑터가 아니라 기반 클래스에 둔다 ```python def discover(self, ctx) -> list: out = [] for uuid in self._raw_candidates(ctx): if uuid in ctx.claimed: continue if ctx.epoch and not self._passes_epoch(uuid, ctx): continue out.append(uuid) return out @abstractmethod def _raw_candidates(self, ctx) -> list: ... # cwd 스코프만 책임진다 def _passes_epoch(self, uuid, ctx) -> bool: # 기본: 아티팩트 mtime ... ``` agy 의 권고는 "`epoch` 필터링과 `claimed` 배제를 어댑터 내부에 캡슐화"였다. **어댑터 4개가 각자 구현하면 잊어버릴 기회가 4번 생긴다.** 방금 그 방식으로 agy·hermes 두 개가 `cwd` 를 잊은 것을 봤다. 그래서 필터는 기반 클래스가 갖고, 어댑터는 **스코프된 원시 후보만** 내놓는다. hermes 만 `_passes_epoch` 를 오버라이드한다(§2.3). ### 3.2 agy 는 두 번째 방어선이 없다 — 그리고 그건 HEAD 도 마찬가지다 Rev.2 를 돌리면 agy 만 검증에서 foreign 을 못 막는다: ``` agy verify_artifact(foreign) -> True claude verify_artifact(foreign) -> False cline verify_artifact(foreign) -> False hermes verify_artifact(foreign) -> False ``` 내 설계 탓인지 확인하려고 **HEAD 의 셸 구현을 직접 호출**했다: ``` HEAD verify_session_uuid(agy, agy-mine) = True HEAD verify_session_uuid(agy, agy-foreign) = True ← 동일 ``` agy 의 `.db` 에는 cwd 가 기록되지 않는다. 캐시가 유일한 스코프 수단이고, HEAD 규칙은 "캐시가 인정하거나, 형제 세션이 점유하지 않았으면 통과"다. 어댑터도 그 규칙을 그대로 옮겼다. **따라서 agy 에 대해서는 `_raw_candidates` 의 cwd 스코핑이 유일한 방어선이다.** 캐시에 이 cwd 항목이 없으면 `[]` 를 반환하도록 명시적으로 정했다 — `[]` 는 고정을 지연시키지만, 전량 반환은 남의 대화를 고정한다. ### 3.3 최종 인터페이스 ```python class BaseAgentAdapter(ABC): name: str = "" own_key: str = "" supports_assigned_id = False ready_tokens: tuple = () # M-2 def auth_ok(self, run) -> bool: ... def spawn_spec(self, binary, session_uuid) -> SpawnSpec: ... def resume_spec(self, binary, uuid, materialized) -> SpawnSpec: ... def artifact_path(self, uuid, ctx) -> str: ... def artifact_exists(self, uuid, ctx) -> bool # 구체 구현 def verify_artifact(self, uuid, ctx) -> bool: ... # cwd 를 반드시 쓸 것 def discover(self, ctx) -> list # 구체 구현 (템플릿) def _raw_candidates(self, ctx) -> list: ... # 추상 def _passes_epoch(self, uuid, ctx) -> bool # 오버라이드 가능 ``` `artifact_path` / `verify_artifact` 도 `ctx` 를 받도록 통일했다. Rev.1 의 `(uuid, ws_key, home, claude_dir, iso_root)` 와 `(path, uuid, cwd)` 두 가지 관례가 공존하던 것이 애초에 `cwd` 를 흘린 원인이다. --- ## 4. M-1 — 우려는 옳고, 제안한 해법은 동작하지 않는다 ### 4.1 우려: 실재한다 Rev.1 은 `PYTHONPATH` 를 `env_python` / `atomic_dump_yaml` 의 env 목록에만 얹었다. 그런데 `run_loop.sh` 는 **맨 `python3 -c` 를 5곳**(179, 210, 223, 250, 277) 쓴다. 그리고 Rev.1 §5 는 하필 그중 `resolve_agent_type`(223)을 `registry.agent_of_row` 로 교체하라고 했다. 재현: ``` $ source .agents/skills/lib.sh; python3 -c "import mam_agents" ModuleNotFoundError: No module named 'mam_agents' ``` Rev.1 설계 그대로 M1 을 구현했다면 `run_loop.sh` 가 그 자리에서 죽는다. ### 4.2 제안: 순환이라 성립하지 않는다 `mam_agents/__init__.py` 안에서 `sys.path.insert` 를 하라는 제안은 실행될 수 없다. `__init__.py` 가 돌려면 패키지가 이미 import 돼야 하고, import 되려면 경로가 이미 잡혀 있어야 한다. ``` $ python3 -c "import mam_agents" # sys.path 에서 skills 제거 후 ModuleNotFoundError: No module named 'mam_agents' -> __init__.py never runs, so it cannot add its own directory to sys.path ``` ### 4.3 채택하는 해법: `lib.sh` source 시점 1회 export ```bash _mam_export_pythonpath() { local d; d="$(mam_skills_dir)" case ":${PYTHONPATH:-}:" in *":$d:"*) ;; *) export PYTHONPATH="$d${PYTHONPATH:+:$PYTHONPATH}" ;; esac } _mam_export_pythonpath ``` `lib.sh` 를 source 하는 **모든** 스크립트의 **모든** 파이썬 호출이 한 번에 덮인다. `run_loop.sh:12` 가 lib.sh 를 source 하므로 5곳 전부 포함된다. 검증: ``` $ source .agents/skills/lib.sh; python3 -c "from mam_agents import registry; print(registry.names())" import OK: ['agy', 'claude', 'cline', 'hermes'] ``` **herdr shim 은 의도적으로 제외된다** — shim 은 lib.sh 를 source 하지 않는 별도 생성 스크립트이고, Rev.1 §3.1 에서 그 안의 python3 9곳이 에이전트 지식을 0건 쓴다는 것을 이미 측정했다. **표준 라이브러리 섀도잉 위험 점검**: `.agents/skills/` 바로 아래에 최상위 `.py` 파일은 **0개**다 (`mam_agents/` 패키지와 스킬 디렉터리뿐). export 후에도 stdlib import 정상: ``` $ source .agents/skills/lib.sh; python3 -c "import json, os, sqlite3, glob, re; print('stdlib OK')" stdlib OK ``` > 남는 부작용 하나: herdr 가 띄우는 에이전트 CLI 들이 이 `PYTHONPATH` 를 상속한다. > 최상위 모듈이 없어 섀도잉은 불가능하지만, 구현자는 `mam_agents` 라는 이름이 > 어느 에이전트 CLI 의 내부 모듈과 겹치지 않는지 한 번 확인하는 편이 좋다. --- ## 5. M-2 — 채택. 효과가 제언보다 크다 agy 는 "5번째 에이전트 추가 시 셸 수정 2~3곳 감소"로 추정했다. 실제로 세어 보니 `_MAM_READY_TOKENS_CLAUDE` 는 **claude 전용 변수 하나**이고, 나머지 세 에이전트의 준비 토큰은 `wait_for_tui_ready` 안에 **인라인으로 박혀 있다**(lib.sh:1811-1835). 그 case 블록이 **25줄**이다. ``` claude Anthropic|Assistant|Chat|Welcome agy Antigravity hermes Hermes cline Cline|history|Chat|What can I do|slash commands ``` 브리지가 `MAM_READY_TOKENS` 를 ERE alternation 으로 내보내면 25줄 case 가 `grep -E -q "$MAM_READY_TOKENS"` 한 줄이 된다. 새 에이전트는 셸을 **0줄** 건드린다. > **행동 변경 주의.** claude 의 ready_tokens 에서 `projects` 를 **뺐다.** > b107cf34 §2.7 에서 그 토큰이 cwd 경로에 우연히 매칭돼 **trust 다이얼로그가 떠 있는 상태에서 > "준비 완료"로 오판**하는 것을 측정했기 때문이다. 이건 개선이지만 리팩터에 섞어 넣을 성질이 아니다. > **별도 커밋으로 분리하고 자체 검증을 붙일 것을 권한다.** --- ## 6. 변경 요약 (Rev.1 대비) | ID | 파일 | 내용 | |---|---|---| | R-1 | `base.py` | `DiscoveryContext` 도입, `discover()` 템플릿 메서드, `_raw_candidates()` 추상화, `_passes_epoch()` 훅, `ready_tokens` 속성 | | R-2 | `adapters/agy.py` | `last_conversations.json[cwd]` 스코핑, 캐시 없으면 `[]`, 검증에 형제 점유 규칙 | | R-3 | `adapters/hermes.py` | `WHERE cwd=?` 복원, `verify_artifact` 에 cwd 대조, `_passes_epoch` 를 `started_at` 으로 오버라이드 | | R-4 | `adapters/cline.py` | 세션 json 의 `cwd` 로 원시 후보 스코핑 | | R-5 | `adapters/claude.py` | `ctx` 서명 통일, `ready_tokens`(`projects` 제외) | | R-6 | `lib.sh` | `PYTHONPATH` 를 source 시점 1회 export (per-entry-point env 목록 방식 폐기) | | R-7 | `__main__.py` | 브리지에 `MAM_READY_TOKENS` 추가 | 패키지 규모: Rev.1 374줄 → **Rev.2 484줄**. 증가분 110줄 대부분이 워크스페이스 스코핑과 필터 템플릿이다. Rev.1 이 그만큼 덜 하고 있었다는 뜻이다. --- ## 7. 검증 ### 7.1 발견 정확도 — 어댑터 4종 워크스페이스 2개(`/work/mine`, `/work/other`), 세션 생성 epoch 1시간 전, 3개월 전 대화 1건, 형제가 점유한 id 1건을 심은 픽스처: | 어댑터 | Rev.1 | Rev.2 | 기대 | |---|---|---|---| | claude | — | `['cl-mine']` | ✅ | | agy | `['agy-mine', 'agy-foreign']` | `['agy-mine']` | ✅ | | hermes | `['herm-mine', 'herm-foreign', 'herm-ancient']` | `['herm-mine']` | ✅ | | cline | — | `['cli-mine']` | ✅ | 형제 점유 배제(전부 claimed 로 표시): ``` agy/claude/cline/hermes discover(all claimed) -> [] 4/4 OK ``` ### 7.2 변이 — 6/6 검출 | 변이 | 되돌린 것 | 결과 | |---|---|---| | Q-1 | agy `_raw_candidates` → 플랫 glob (**Rev.1 그대로**) | `['agy-foreign', 'agy-mine']` WRONG | | Q-2 | hermes `WHERE cwd=?` 제거 (**Rev.1 그대로**) | `herm-foreign` 유입 | | Q-3 | hermes `_passes_epoch` 오버라이드 제거 | `herm-ancient` 유입 | | Q-4 | 기반 클래스의 `claimed` 필터 제거 | 4종 전부 LEAKED | | Q-5 | 기반 클래스의 `epoch` 필터 제거 | claude·cline·hermes 에 ancient 유입 | | Q-6 | cline cwd 스코핑 제거 | `cli-foreign` 유입 | Q-1·Q-2 는 **Rev.1 코드를 그대로 변이로 삼은 것**이고 실제로 깨진다. Q-3 은 §2.3 의 hermes 특수성이 공허한 우려가 아님을 보인다. ### 7.3 회귀 ``` baseline (HEAD 245abe6) 162 passed in 518.51s Rev.1 프로토타입 162 passed in 521.54s Rev.2 프로토타입 162 passed in 505.79s ← 회귀 0 ``` R-6(source 시점 `PYTHONPATH` export)이 가장 위험했다. `lib.sh` 를 source 하는 모든 스크립트의 환경을 바꾸고 herdr 가 띄우는 프로세스까지 상속되기 때문이다. 회귀 0. `py_compile` 통과. 어댑터는 표준 라이브러리만 사용(§Rev.1 3.2 제약 유지). --- ## 8. 남는 위험 (Rev.1 §9 갱신) Rev.1 의 비용 항목 5가지(인터프리터 경계 · 브리지 호출 규율 · 배포/CI 등록 · 이행 중 이중 표현 · 간접화)는 그대로 유효하다. 아래는 갱신·추가분. **8.1 (갱신) 배포·CI 등록** — Rev.1 §8.2 의 `deploy/remove.sh` 한 줄과 §8.4 의 CI 경로 2줄은 Rev.2 에서도 그대로 필수다. **8.2 (신규) hermes 스키마 미검증** — §2.3. `sessions(id, cwd, started_at)` 은 기존 쿼리와 mock 에서 역산했다. hermes 미설치라 실 CLI 대조 불가. **M4 착수 전 확인 필요.** **8.3 (신규) agy 의 단일 방어선** — §3.2. agy 는 검증 단계에서 foreign 을 못 막는다(HEAD 동일). 캐시가 침묵하면 `[]` 를 반환하는 선택이 유일한 보호막이므로, 이 동작은 **테스트로 고정**해야 하고 "후보가 안 잡힌다"는 버그 리포트가 올라올 때 되돌리고 싶어질 지점이다. 되돌리면 격리가 깨진다. **8.4 (신규) `projects` 토큰 제거는 행동 변경** — §5. 리팩터와 분리할 것. **8.5 (신규) `PYTHONPATH` 상속** — §4.3. 에이전트 CLI 들이 상속한다. 섀도잉 위험은 측정상 없으나 이름 충돌 여부는 구현자가 확인. --- ## 9. 이행 순서 (Rev.1 §10 갱신) | 단계 | 내용 | 변경점 | |---|---|---| | **M0** | 패키지 골격 + **source 시점 `PYTHONPATH` export**(R-6) + `deploy/remove.sh`·`install.sh`·CI 등록 | 부트스트랩 방식 교체 | | **M1** | `own_key` / `agent_of_row` 이관 (프로토타입 완료, 34 → 29) | 변경 없음 | | **M2** | `artifact_path` + `verify_artifact` — **`ctx` 서명으로 통일**, 격리 경로 일원화 | 서명 변경 | | **M3** | `spawn_spec` / `resume_spec` / `auth_ok` | 변경 없음 | | **M4** | `discover()` — drift-C 4블록. **hermes 스키마 확인이 선행**(§8.2) | 선행 조건 추가 | | **M5** | `stop_session.sh` purge 경로 + exit key | 변경 없음 | | **M6** | **(신규)** `ready_tokens` — `wait_for_tui_ready` 25줄 case 제거 | M-2 | | **M7** | **(신규·별건)** claude ready token 에서 `projects` 제거 + 자체 검증 | §5 | 중단 기준은 그대로: M2 이후 팬아웃이 29 → 20 이하로 안 떨어지면 재검토. --- ## 10. 결론 이의 1건과 제언 2건 전부 채택했다. 그리고 셋 다 조사해 보니 지적된 것보다 컸다 — C-1 은 서비스 거부가 아니라 **격리 위반**이었고, M-1 은 `run_loop.sh` 를 **죽이는** 문제였으며, M-2 는 2~3곳이 아니라 **25줄 블록**이었다. 그대로 채택하지 않은 것 하나. agy 의 권고는 `epoch`/`claimed` 를 **어댑터마다** 캡슐화하라는 것인데, 어댑터 4개가 각자 구현하면 잊어버릴 기회가 4번 생긴다. 방금 그 방식으로 두 개가 `cwd` 를 잊은 것을 확인했다. 필터는 기반 클래스가 갖고, 어댑터는 스코프된 원시 후보만 낸다. 프로토타입 트리: `scratchpad/ad2`(Rev.2) · `scratchpad/ad`(Rev.1) · `scratchpad/adbase`(HEAD). `IMPROVEMENTS.md` A-4 항목은 Creator 구현 시 본 Rev.2 기준으로 갱신이 필요하다 — 이번 작업에서는 저장소를 건드리지 않았다. **[AGREEMENT: REACHED]**