# 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]**
