# B-1 구현 계획서 **Rev.2** — `find_workspace_uuid` tier-3 신원 캐시 복구 - **Job**: `662f07ba` (Planner) — 원안 `64990742` Rev.1 을 Creator 이의제기 `2b9e9b67`(agy) 반영하여 개정 - **대상**: `IMPROVEMENTS.md` §2 / **B-1** - **기준 커밋**: `b6c41e6` (작업 트리 clean) - **작성 원칙**: 이의제기의 **전제와 처방을 각각 따로 실행하여** 판정한다. 상대 코드를 그대로 돌려 보지 않고 논평하지 않는다. --- ## 0. 이의제기 판정 요약 (Adjudication) | 대상 | 판정 | 근거 (실측) | |---|---|---| | **전제** — "`d`가 truthy인데 `agent_identities`가 없고, YAML에는 남아 있는 상태에서 폴백이 영구 차단된다" | **기각 (Refuted)** | `load_state_json`과 폴백은 **같은 base 경로 · 같은 DB-우선 우선순위**로 읽는다. 6가지 디스크 상태 전수 측정 결과 네 변형이 **전 셀 동일** | | **처방** — `if isinstance(d, dict) and d.get('agent_identities'):` 로 바꾸면 하위호환 신원이 "완벽히 복원"된다 | **기각 (Refuted)** | 제안 코드를 **원문 그대로 실행**했다. 문제로 지목된 **S2·S6 셀에서 여전히 miss**. 폴백에는 진입하지만(probe=1) 폴백 역시 DB를 먼저 읽어 YAML에 도달하지 못한다 | | **처방의 경로 폴백** — `os.environ.get('YAML_PATH','')` → 없으면 `/.mam/...` 추측 | **거부 (Rejected)** | 본 계획서가 제거하려는 **D1(경로 추측)을 그대로 재도입**한다. 직전 잡 `4dbf4feb`의 `${1:-$PWD}`와 동일한 패턴 | | **조건 변경 자체** | **채택 (Adopted — 단, 근거는 다름)** | 하위호환 복구 목적으로는 무의미하나, **`load_state_json`의 반환 형태 변경에 대한 내성**으로는 유효. 재조회 비용 실측 **0.063 ms** → 성능을 반대 논거로 쓰지 않는다 | | 원안의 나머지 (D1·D3·D4·D5, V-1…V-6, 커밋 분할) | **유지** | 이의 없음 | > **요약**: agy가 지적한 셀(S2/S6)은 **agy의 코드로도 고쳐지지 않는다**. 그 셀을 실제로 고치려면 **DB(권위)를 제치고 YAML(미러)을 읽어야** 하는데, 이는 `atomic_dump_yaml`의 설계와 정면으로 충돌한다. 따라서 Rev.2는 **조건 변경은 받되(근거 교체), 경로 추측은 거부하고, S2/S6를 "의도된 동작"으로 테스트에 못박는다**(V-8). --- ## 1. 전제 검증 — `d`와 폴백은 같은 것을 읽는다 `load_state_json`(`lib.sh:510-545`)의 소스 결정 로직: ```python yaml_path = os.environ['YAML_PATH'] db_path = os.path.splitext(yaml_path)[0] + '.db' if os.path.exists(db_path): d = json.loads() ; d['herdr_sessions'] = elif os.path.exists(yaml_path): d = yaml.safe_load(f) ``` Rev.1 폴백과 **완전히 동일한 base 경로, 동일한 DB-우선 우선순위**다. 따라서 `d`가 신원을 못 가졌다 ⟹ 폴백도 같은 자리에서 같은 것을 읽어 못 가진다. 이의제기가 상정한 "`d`에는 없고 디스크에는 있는" 상태가 성립하려면 두 읽기의 **소스가 갈려야** 하는데 갈릴 수 없다. ### 1-1. 6가지 디스크 상태 전수 측정 tier-1(관계없는 세션 1행)·tier-2(`last_conversations.json` 부재)를 굶겨 **tier-3만** 남긴 상태에서 네 변형을 각각 실행: | 상태 | 디스크 배치 | HEAD | Rev.1 `if d:` | **agy 제안** | Rev.2 | |---|---|---|---|---|---| | S1 | DB=신원 있음, YAML=신원 있음 | HIT | HIT | HIT | HIT | | **S2** | **DB=신원 없음, YAML=신원 있음** ← *이의제기 시나리오* | miss | miss | **miss** | miss | | S3 | YAML만 존재, 신원 있음 | HIT | HIT | HIT | HIT | | S4 | YAML만 존재, 신원 없음 | miss | miss | miss | miss | | S5 | DB만 존재, 신원 없음 | miss | miss | miss | miss | | **S6** | **DB에 state 행 없음 + sessions 행 존재, YAML=신원 있음** | miss | miss | **miss** | miss | **네 변형이 6/6 셀에서 동일하다.** 조건 변경은 해석 결과를 단 한 셀도 바꾸지 못한다. S3가 HIT인 점에 주목할 것 — "YAML에만 신원이 있는" 정상 상태는 `load_state_json`이 `elif` 분기로 YAML 전체를 싣기 때문에 **`d`가 이미 신원을 갖는다**. 이의제기가 걱정한 하위호환 경로는 Rev.1에서 이미 살아 있었다. --- ## 2. 처방 검증 — 제안 코드를 그대로 실행했다 이의제기 §3의 코드를 **한 글자도 바꾸지 않고**(폴백 진입 여부를 세는 `PROBE` 한 줄만 추가) 위 매트릭스에 투입했다. S2 결과: ``` S2 agy=miss probes=1 ``` `probes=1`은 **폴백에 진입했다**는 뜻이다. 그런데도 miss인 이유는 폴백의 첫 분기가 `if os.path.exists(db_path):` 이기 때문이다. DB가 존재하므로 YAML `elif`는 평가조차 되지 않고, DB의 state 행에는 신원이 없으므로 `ai = {}`로 끝난다. **폴백이 열렸을 뿐 도달하지 못한다.** S6도 같다 — DB는 존재하고 state 행만 없어 `row = None` → `ai = {}`. > 이의제기 §2-2의 결론 *"tier-3 캐시 조회를 100% 영구 차단"* 은 **Rev.1이 아니라 두 변형 모두에** > 해당하며, 제안된 수정은 그 차단을 해제하지 못한다. 전제와 처방이 함께 성립하지 않는다. ### 2-1. S2/S6를 진짜로 고치려면 무엇이 필요한가 (그리고 왜 하지 않는가) DB에 없고 YAML에만 있는 신원을 살리려면 **DB 조회가 비었을 때 YAML로 넘어가는 union 방식**이어야 한다 (`if ... elif ...`가 아니라 `if not ai and os.path.exists(yaml_path): ...`). **그렇게 하지 않는다.** `atomic_dump_yaml`(`lib.sh:682-920`)은 SQLite `BEGIN IMMEDIATE` 트랜잭션을 권위로 삼고 YAML은 그 뒤에 temp+rename으로 쓰는 **미러**다. DB에서 사라진 신원을 YAML에서 되살리면: - `stop_session.sh --purge-conversation`이 DB에서 지운 신원이 **YAML 미러를 통해 부활**한다. (`stop_session.sh:333-348`이 지우는 대상이 바로 이 캐시다.) - 두 파일이 갈린 상태 = 이미 손상된 상태이며, 손상된 미러를 권위보다 우선시키는 복구는 **P0-C 워크스페이스 격리 보증(다른 워크스페이스 UUID 절대 반환 금지)** 을 무너뜨릴 수 있다. 따라서 S2/S6의 miss는 **버그가 아니라 의도된 동작**이며, Rev.2는 이를 **V-8로 못박아** 후대의 "친절한 복구" 시도를 차단한다. --- ## 3. 처방의 경로 폴백 거부 ```python yaml_path = os.environ.get('YAML_PATH', '') if not yaml_path: yaml_path = os.path.join(ws, ".mam", "agent-sessions.yaml") # ← D1 재도입 ``` `YAML_PATH`는 `env_python`(`lib.sh:661-662`)이 **무조건** 설정한다. 이 블록은 `env_python`을 통해서만 실행되므로 이 가드는 **도달 불가능한 죽은 코드**다. 그리고 만에 하나 도달한다면(= 이 블록이 `env_python` 밖에서 실행되는 미래) 그때야말로 워크스페이스 기준 추측이 **가장 위험**하다 — `AGENT_SESSIONS_YAML`이 재정의된 배치에서 조용히 엉뚱한 파일을 읽는다. 이는 본 계획서가 제거하려는 D1 그 자체이며, `ea36e81`의 `if 'mam_dir' in locals()`와 **구조가 동일한 죽은 가드**다. **대신 채택하는 하드닝**: 경로 해석을 `try` **안쪽**으로 옮긴다. `os.environ['YAML_PATH']`를 그대로 쓰되(추측 없음), 만약 미설정이면 `KeyError`가 `except`에 잡혀 `WARN` 한 줄과 `ai={}`로 수렴하여 **`rc=0` 계약이 유지된다**. Rev.1은 이 줄이 `try` 밖에 있어 `KeyError` 시 `rc=1`이 될 수 있었다 — 이의제기가 간접적으로 드러낸 실질적 개선점이며, Rev.2에 반영했다. (이 경로는 공개 인터페이스로는 재현 불가하다. `env_python`이 항상 변수를 넣기 때문이다. 따라서 **테스트를 붙이지 않고** 구조적 방어로만 남긴다 — 검증 불가한 것을 검증했다고 적지 않는다.) --- ## 4. 조건 변경 채택 — 근거를 교체하여 해석 결과가 6/6 동일하므로 **하위호환 복구를 근거로 한 채택은 불가**하다. 그럼에도 조건 자체는 채택한다. 근거는 하나뿐이다: > `if d:`는 "**`load_state_json`이 병합 상태에 `agent_identities`를 실어 준다**"는 **암묵적 결합**에 > 의존한다. 이 저장소는 이미 state 블롭에서 `sessions` 테이블을 분리한 전례가 있고(현재 라이브 DB의 > `state.data` 최상위 키는 `['snapshot']` 뿐이다), 같은 방식으로 신원이 분리되면 `if d:`는 > **조용히** 폴백을 건너뛴다. 비용은 실측했다: 재조회 1회 = `sqlite3.connect` + `SELECT` = **0.063 ms/call** (200회 평균, 로컬 SSD). tier-3은 tier-1·tier-2가 모두 실패했을 때만 도달하는 경로이므로 **성능은 반대 논거가 되지 못한다.** 성능을 이유로 거부하지 않는다는 점을 명시한다. 다만 조건 변경만으로는 위 결합을 **감지**하지 못하고 **은폐**할 뿐이므로(폴백이 조용히 대신 일한다), **V-7 결합 테스트를 함께 넣는다**. 런타임 우회 + CI 감지 두 겹이 되어야 실제 방어가 된다. **N-1과의 상호작용(중요)**: 저장소 어디에도 `agent_identities` **쓰기 코드가 없으므로** (§5 참조) 실사용에서 `d.get('agent_identities')`는 사실상 항상 falsy다. 즉 조건 변경 후 **폴백이 사실상 상시 경로가 된다**. 이는 무해하지만, "1차 경로는 재조회 없음"이라던 Rev.1의 설계 근거 ②는 **더 이상 성립하지 않는다**. D2의 의의는 "재조회 제거"가 아니라 **"경로 추측 제거"** 로 축소 기술한다. --- ## 5. HEAD 실측 (Rev.1에서 유지) ### 5-1. 현재 코드 (`.agents/skills/lib.sh:1266-1282`) ```python ai = {} db_path = f"{mam_dir}/agent-sessions.db" if 'mam_dir' in locals() else os.path.join(ws, ".mam", "agent-sessions.db") yaml_path = f"{mam_dir}/agent-sessions.yaml" if 'mam_dir' in locals() else os.path.join(ws, ".mam", "agent-sessions.yaml") try: import yaml if os.path.exists(db_path): ... elif os.path.exists(yaml_path): with open(yaml_path) as f: d = yaml.safe_load(f) or {} # ← 상태 딕셔너리 d 를 덮어씀 ai = d.get('agent_identities', {}) except Exception as e: print(f"WARN: tier-3 identity lookup failed: {e}", file=sys.stderr) ``` `git blame`: 1267·1268·1270·1281·1282 행이 `ea36e81`(2026-08-05), 나머지는 2026-06-21 원본. **B-1 원문의 `NameError`는 `ea36e81`에서 이미 제거되었다** — 전제는 낡았고, 증상 대신 경로 추측이 남았다. ### 5-2. 잔존 결함 재현 매트릭스 | # | 조건 | HEAD | Rev.2 | |---|---|---|---| | P1 | 상태 파일이 `/.mam/`에 있음 (기본 배치) | `` ✅ | `` ✅ | | **P2** | `AGENT_SESSIONS_YAML`이 `/.mam/` **밖** | **`""` 무음** ❌ | `` ✅ | | **P3** | `.db` 존재 + PyYAML 없음 | **`""`** ❌ | `` ✅ | | P4 | `agent_identities` 없음 | `""` ✅ | `""` ✅ | | P5 | `project_cwd` 불일치 | `""` ✅ | `""` ✅ | | **P7** | `agent_identities`가 dict 아님 | **Traceback, `rc=1`** ❌ | `""`, `rc=0` ✅ | | **P8** | hermes 신원이 `conversation_id` 키 | **`""`** ❌ | `` ✅ | --- ## 6. 결함 목록 ### **D1 — 상태 파일 경로를 추측한다 (핵심)** `env_python`이 `YAML_PATH`로 권위 경로를 넘겨주고(`lib.sh:661-662`), 같은 파일의 다른 두 블록은 이를 지킨다(`load_state_json` L512, `atomic_dump_yaml` L746). tier-3만 `os.path.join(ws, ".mam", ...)`를 직접 조립한다. `AGENT_SESSIONS_YAML`은 `lib.sh:26`에서 재정의 가능하고 해석 대상 워크스페이스는 `WORKSPACE_ROOT`와 같을 의무가 없다 → 갈리는 순간 무음 실패(P2). `if 'mam_dir' in locals()` 가드는 `mam_dir` 정의가 0건이므로 **항상 False**인 죽은 코드다. ### **D2 — 이미 로드한 상태를 다시 읽는다** *(Rev.2에서 축소 기술)* `find_workspace_uuid`는 진입 시 `MAM_STATE_JSON`으로 병합 상태를 받아 `d`에 담는다(L1130-1133). tier-3의 두 번째 읽기는 이 중복이 D1·D3·D4를 낳은 **구조적 원인**이다. 단, §4에 따라 Rev.2는 재조회 자체를 제거하지 않는다 — **경로 추측만 제거**한다. ### **D3 — `import yaml`이 SQLite 분기까지 죽인다** `try` 첫 줄의 `import yaml` 때문에 PyYAML 부재 시 YAML이 필요 없는 `.db` 분기까지 무력화된다(P3). **도달 가능성**: `deploy/install.sh:86`이 *시스템* python3의 PyYAML을 하드 게이트하지만 `_delegate_py_bin`(L1416-1421)은 **`$VIRTUAL_ENV`를 최우선** 선택한다. 무관한 venv 활성 상태에서 게이트를 우회한다 — stub 없이 실제 bare venv로 실측: ``` picked python: .../barevenv/bin/python HEAD out= ``` ### **D4 — `d` 섀도잉 + `rc=0` 계약 위반** YAML 분기의 `d = yaml.safe_load(f)`가 tier-1/2의 상태 딕셔너리를 덮어쓴다(현재는 이후 미사용이라 무해하나, 한 줄만 추가돼도 즉시 오동작하는 함정). 더 심각한 것은 `ai.get(agent)`(L1284)가 `try` **밖**이라 비-매핑 입력 시 `AttributeError` → `rc=1`이 된다는 점이다(P7). 함수 주석의 **"Always exits 0"**(L1106) 위반이며, `resolve_session_id.sh:44`는 `set -euo pipefail` 아래 **마지막 명령**이라 그대로 스크립트 종료 코드가 된다(`stop_session.sh:140,159`는 `|| true`로 방어됨). ### **D5 — hermes/cline 폴백이 잘못된 딕셔너리를 본다** ```python cand = ai_agent.get('session_id') or ai.get('conversation_id') # L1295, L1299 ``` `ai`는 `{agent: {...}}` 맵이므로 `ai.get('conversation_id')`는 **항상 None**. cline은 tier-2 디스크 스캔에 가려지지만, hermes는 tier-2가 `WHERE cwd=?`로 조회하므로 hermes DB의 cwd가 다르면 tier-2가 실패하고 tier-3만 남는다 → 폴백이 죽는다(P8). --- ## 7. 인접 발견 (B-1 범위 밖 — 별도 등재) ### **N-1 — `agent_identities` 쓰기 코드가 저장소에 0건** 전수 조사: `lib.sh:1275,1280`(읽기), `stop_session.sh:334`(기존 항목 비우기), `reconcile.sh:690`(진단 읽기). **생성·갱신 0건**, `git log -S"d['agent_identities']"`도 공집합. 라이브 워크스페이스의 `state.data` 최상위 키는 `['snapshot']`뿐이고 `agent_identities`는 `null`이다. → B-1을 완벽히 고쳐도 **현행 코드로 생성된 워크스페이스의 복원 건수는 0**이다. tier-3은 (a) 마이그레이션된 YAML, (b) 수동 편집 상태를 위한 **하위호환 읽기 경로**로만 의미를 갖는다. §4에서 본 것처럼 이 사실은 조건 변경의 실효(폴백이 상시 경로가 됨)에도 직결된다. ### **N-2 — `load_state_json`의 PyYAML 하드 의존** `lib.sh:511`이 모듈 최상단에서 `yaml`을 import 하여, `.db`만으로 충분한 경우에도 PyYAML 부재 시 traceback과 함께 `d={}`가 되어 **tier-1·tier-2가 함께 붕괴**한다(P3 stderr에서 관측). --- ## 8. 왜 기존 55개 테스트가 이를 잡지 못했는가 `tests/conftest.py::mam_sandbox`는 `AGENT_SESSIONS_YAML = tmp_path/.mam/agent-sessions.yaml`, `WORKSPACE_ROOT = tmp_path`로 두고, 테스트는 `find_workspace_uuid {tmp_path} ...`로 호출한다. 따라서 추측 경로 `/.mam/agent-sessions.yaml`과 권위 경로가 **모든 테스트에서 문자 단위로 동일**하다. 하네스가 구조적으로 D1을 볼 수 없다. → 신규 테스트는 **워크스페이스 디렉터리와 상태 파일 디렉터리를 분리한 `split_sandbox`**를 쓴다. --- ## 9. 구현 계획 > 원자적·이등분 가능 커밋 4개. F1↔F2↔F3 사이 순서 제약 없음(각 테스트가 서로 독립적으로 red). ### **F1 — tier-3 상태 소스 교정** (D1·D3·D4 해소, D2 축소) — **Rev.2 개정** `lib.sh:1266-1282` 전체를 아래로 치환한다. (프로토타입 검증 완료) ```python ai = d.get('agent_identities') if isinstance(d, dict) else None if not isinstance(ai, dict) or not ai: ai = {} try: yaml_path = os.environ['YAML_PATH'] db_path = os.path.splitext(yaml_path)[0] + '.db' if os.path.exists(db_path): conn = sqlite3.connect(db_path, timeout=60.0) row = conn.execute('SELECT data FROM state WHERE id=1').fetchone() if row: ai = json.loads(row[0]).get('agent_identities') or {} conn.close() elif os.path.exists(yaml_path): import yaml with open(yaml_path) as f: _ydoc = yaml.safe_load(f) or {} ai = _ydoc.get('agent_identities') or {} except Exception as e: print(f"WARN: tier-3 identity lookup failed: {e}", file=sys.stderr) if not isinstance(ai, dict): ai = {} ``` **Rev.1 → Rev.2 변경 2건:** | # | 변경 | 이유 | |---|---|---| | ① | `if d:` → `d`에서 **신원을 실제로 확보했는지**로 분기 | 이의제기 채택. 단 근거는 하위호환이 아니라 **`load_state_json` 반환 형태 변경 내성**(§4). V-7과 한 쌍으로만 유효 | | ② | `yaml_path`/`db_path` 해석을 **`try` 안쪽**으로 이동 | `YAML_PATH` 미설정 시 `KeyError`가 `rc=1`이 되는 잔여 계약 위반 차단(§3) | **거부한 변경 1건**: `os.environ.get('YAML_PATH','')` → `/.mam/...` 추측 폴백. D1 재도입(§3). 설계 근거: 1. **1차 소스는 이미 로드된 `d`** — 정상 경로에서 재조회 없음. 2. **폴백은 `YAML_PATH` 기반, 추측 없음** — L512/L746과 동일 규약. `.db` 파생도 동일. 3. **`import yaml`은 YAML 분기 안** — SQLite 분기가 PyYAML에 인질 잡히지 않는다. 4. **`_ydoc` + 이중 `isinstance` 가드** — 섀도잉 제거, `rc=0` 계약 복원. ### **F2 — hermes/cline 폴백 키 수정** (D5) ```diff - cand = ai_agent.get('session_id') or ai.get('conversation_id') + cand = ai_agent.get('session_id') or ai_agent.get('conversation_id') ``` `lib.sh:1295`, `lib.sh:1299` 두 곳. **`replace_all` 금지** — 두 줄이 완전히 동일하므로 문맥(`elif agent == 'hermes':` / `'cline'`)을 포함해 개별 치환할 것. ### **F3 — 회귀 테스트 신설** 신규 파일 `tests/test_b1_tier3_identity.py` (V-1…**V-8**). 전용 `split_sandbox` 픽스처를 파일 내부에 둔다 — `conftest.py::mam_sandbox`를 고치면 기존 55개 테스트의 전제가 흔들리므로 **공용 픽스처는 건드리지 않는다**. ### **F4 — 주석·문서 정합** - `lib.sh:1105` tier-3 설명에 상태 소스가 `d`(1차)와 `$YAML_PATH`(폴백)임을 명시. - **DB가 권위, YAML은 미러**이며 tier-3은 미러를 권위보다 앞세우지 않는다는 한 줄을 추가(§2-1). - `IMPROVEMENTS.md` B-1 항목 교체 + B-10/B-11 신규 등재(§11). --- ## 10. 테스트 계획 (red-before-green 실측 완료) | ID | 테스트명 | 검증 대상 | HEAD | Rev.2 | |---|---|---|---|---| | V-1 | `test_b1_tier3_honours_agent_sessions_yaml_path` | D1 — 워크스페이스≠상태파일 디렉터리 | **FAIL** | PASS | | V-2 | `test_b1_tier3_db_branch_survives_missing_pyyaml` | D3 — PyYAML 부재 시 SQLite 분기 생존 | **FAIL** | PASS | | V-3 | `test_b1_tier3_corrupt_identities_still_exits_zero` | D4 — `rc=0` 계약 | **FAIL** | PASS | | V-4 | `test_b1_tier3_hermes_conversation_id_fallback` | D5 — hermes 폴백 키 | **FAIL** | PASS | | V-5 | `test_b1_tier3_refuses_foreign_workspace_identity` | P0-C 격리 유지 (가드) | PASS | PASS | | V-6 | `test_b1_tier3_absent_identities_is_silent` | 무신원 시 무음·무예외 (가드) | PASS | PASS | | **V-7** | `test_b1_load_state_json_surfaces_agent_identities` | **결합 고정** — `load_state_json`이 신원을 `d`에 실어야 함 | PASS | PASS | | **V-8** | `test_b1_tier3_does_not_read_yaml_mirror_behind_the_db` | **동작 고정** — stale DB를 YAML 미러로 되살리지 않음 | PASS | PASS | **V-7·V-8은 이의제기 대응으로 신설**했다. 둘 다 **HEAD에서도 green인 고정(pinning) 테스트**다 — red를 만드는 것이 목적이 아니라, §1·§2에서 측정으로 확인한 사실을 **코드로 못박아** 다음 리팩터에서 조용히 깨지는 것을 막는 것이 목적이다. V-7이 깨지면 §4의 결합이 끊어진 것이고, V-8이 깨지면 누군가 미러를 권위보다 앞세운 것이다. 4개 변형 전수 실행 결과: ``` HEAD 4 failed, 4 passed in 1.23s REV1 8 passed in 1.18s (if d:) AGY 8 passed in 1.19s (이의제기 제안 원문) REV2 8 passed in 1.20s (채택안) ``` > 세 수정안이 **동일하게 8 passed**라는 사실 자체가 §1의 결론을 다시 확인해 준다 — > 조건 변경은 관측 가능한 동작을 바꾸지 않는다. 핵심 픽스처(구현자는 이 구조를 그대로 쓸 것): ```python @pytest.fixture def split_sandbox(tmp_path): """워크스페이스와 상태 파일을 서로 다른 디렉터리에 둔다 — mam_sandbox 의 우연을 깬다.""" shutil.copytree(os.path.join(REPO, ".agents", "skills"), tmp_path / ".agents" / "skills") ws = tmp_path / "ws"; (ws / ".mam").mkdir(parents=True) state_dir = tmp_path / "state"; state_dir.mkdir() home = tmp_path / "home"; (home / ".claude" / "projects").mkdir(parents=True) return {"root": tmp_path, "ws": ws, "yaml": state_dir / "agent-sessions.yaml", "home": home, "lib": tmp_path / ".agents" / "skills" / "lib.sh"} ``` V-2의 PyYAML 부재는 스텁 모듈(`PYTHONPATH=/noyaml`의 `yaml/__init__.py`에서 `raise ImportError`)로 결정적으로 재현한다 — 실제 venv 생성 없이 CI에서 빠르고 안정적이다. 검증에 사용한 전체 테스트 파일(8케이스)은 저장소 밖 스크래치패드에 그대로 있다: `/private/tmp/claude-501/.../scratchpad/b1/test_b1_tier3_identity.py` --- ## 11. 검증 게이트 | 게이트 | 명령 | 통과 기준 | 실측 | |---|---|---|---| | **G-A** | `bash -n .agents/skills/lib.sh` | 구문 통과 | 통과 | | **G-B** | `pytest tests/test_b1_tier3_identity.py -q` (수정 **전**) | **4 failed, 4 passed** | 확인 | | **G-C** | `pytest tests/test_b1_tier3_identity.py -q` (수정 **후**) | **8 passed** | 확인 | | **G-D** | `pytest tests/test_tier1_unit.py tests/test_tier2_component.py -q` | **55 passed** (회귀 0) | Rev.2 패치 클론에서 **55 passed / 459.81s** | | **G-E** | `pytest tests/test_tier2_component.py -k "find_workspace_uuid or resume or stop"` | 10 passed | 확인 (13.3s) | - G-D는 MQTT 왕복 때문에 ~8분 소요된다. 백그라운드 실행 후 회수할 것. - `tests/test_sanity.py`는 **HEAD 이전부터 45초 이상 행(hang)** 하는 기존 문제로 본 작업 범위 밖이다 (baseline 아카이브 대조 확인 완료). 게이트에 포함하지 않는다. --- ## 12. `IMPROVEMENTS.md` 갱신 문안 ```markdown ### **B-1: `find_workspace_uuid` tier-3 신원 캐시 해석 오류** — ✅ 해소 (F1/F2) - tier-3이 상태 파일 경로를 `/.mam/` 로 추측하던 문제를 제거하고, 이미 로드된 병합 상태(`d`)를 1차 소스로, `$YAML_PATH`(권위 경로)를 폴백으로 사용하도록 교정. - PyYAML 부재 시 SQLite 분기까지 무력화되던 `import yaml` 위치, 상태 딕셔너리 `d` 섀도잉, 비정상 `agent_identities` 입력 시 `rc=1`(계약 위반), hermes/cline 폴백 키 오류를 함께 수정. - **DB가 권위, YAML은 미러**이며 tier-3은 미러를 권위보다 앞세우지 않는다(V-8로 고정). - 회귀 테스트 `tests/test_b1_tier3_identity.py` (V-1…V-8) 신설. ### **B-10: `agent_identities` 쓰기 경로 부재** - 저장소 전체에 `agent_identities` 를 생성·갱신하는 코드가 **0건**이며, 읽기(lib.sh tier-3, reconcile.sh 진단)와 삭제(stop_session.sh --purge-conversation)만 존재한다. 현행 코드로 생성된 워크스페이스에서 tier-3은 구조적으로 빈 값이며, 하위 호환 읽기 경로로만 기능한다. - 결정 필요: (a) 세션 캡처 시 기록하는 쓰기 경로 신설 / (b) 하위 호환 전용으로 명시하고 문서화. ### **B-11: `load_state_json` 의 PyYAML 하드 의존** - `lib.sh:511` 이 모듈 최상단에서 `yaml` 을 import 하여, `.db` 만으로 충분한 경우에도 PyYAML 부재 시 traceback 과 함께 상태가 `{}` 로 붕괴되어 tier-1·tier-2 가 동시에 무력화된다. - `_delegate_py_bin` 이 `$VIRTUAL_ENV` 를 최우선 선택하므로 `deploy/install.sh` 의 시스템 python3 PyYAML 게이트를 우회하는 경로가 실재한다(실측). ``` 총 추적 건수 18 → 19 (B-1 해소, B-10·B-11 신규). --- ## 13. 리스크 | ID | 리스크 | 평가 | 완화 | |---|---|---|---| | RK-A | 폴백 진입 조건 변경으로 tier-3이 느려진다 | **무시 가능** — 재조회 0.063 ms/call, tier-1·2 실패 시에만 도달 | 실측 기재. 성능을 논거로 쓰지 않음 | | **RK-B** *(개정)* | `d`에 신원이 있는지로 분기하면 **폴백이 상시 경로가 된다**(N-1 때문) | **중** — 동작은 동일하나 "재조회 없음" 설계 근거가 소멸 | D2를 "경로 추측 제거"로 축소 기술. V-7이 결합 파손을 CI에서 검출 | | RK-C | `isinstance` 가드가 정상 입력을 거른다 | **없음** — dict일 때 무동작 | V-5·V-6 | | RK-D | F2 치환 시 동일 문자열 2줄을 `replace_all`로 뭉갠다 | 중 | 문맥 포함 개별 치환 강제 | | RK-E | 수정 후에도 실사용 복원이 늘지 않아 "미해결"로 오판 | **높음** | N-1(B-10) 동반 보고. 효과 범위를 **하위호환 상태를 가진 워크스페이스**로 한정해 커밋 메시지에 명시 | | **RK-F** *(신규)* | 후대에 "S2/S6도 살리자"며 YAML 미러 union 폴백을 추가한다 | **중** — `--purge-conversation`으로 지운 신원 부활, P0-C 격리 훼손 | **V-8이 즉시 red**. §2-1 근거를 F4 주석으로 코드 옆에 남긴다 | | **RK-G** *(신규)* | `YAML_PATH` 미설정 하드닝(변경 ②)이 공개 인터페이스로 검증 불가 | 낮음 | 테스트를 만들지 않고 **구조적 방어로만** 남긴다고 명시. 검증하지 않은 것을 검증했다고 적지 않음 | --- ## 14. 작업 순서 체크리스트 (Creator용) 1. `tests/test_b1_tier3_identity.py` 추가 → **G-B(4 failed, 4 passed)** 확인. *red 미확인 시 진행 금지.* 2. F1 적용(`lib.sh:1266-1282` 치환) → G-A, G-C. 3. F2 적용(`lib.sh:1295,1299` 개별 치환) → G-C 재확인. 4. G-E → G-D(백그라운드)로 회귀 0 확인. 5. F4 주석/문서 → `IMPROVEMENTS.md` §12 문안 반영. 6. 커밋 분할: - `fix(lib): resolve tier-3 identity source from loaded state and $YAML_PATH (B-1)` - `fix(lib): read hermes,cline tier-3 fallback from the agent entry` - `test(lib): add B-1 tier-3 regression suite (V-1..V-8)` - `docs(improvements): close B-1, open B-10,B-11` --- ## 15. 경계 선언 본 문서는 **설계·리뷰 산출물**이며 저장소 코드는 한 줄도 수정하지 않았다. 이의제기 코드·Rev.1·Rev.2 세 변형과 8개 테스트는 전부 스크래치패드의 `git archive HEAD` 클론 안에서만 실행했고, 작업 트리는 호출 시점과 동일하게 clean이다(`b6c41e6`). herdr 명령 실행 없음, 라이브 세션 미접촉. `MULTI_AGENT_RULES.md` §1에 따라 **구현은 Creator, 커밋은 GM 소관**이다. **차단 항목 없음.** Creator께: 이의제기의 **전제와 처방은 측정으로 기각**되었으나, 그 과정에서 **잔여 계약 위반(변경 ②)** 을 발견했고 **V-7·V-8 두 개의 고정 테스트**를 얻었습니다. 지적해 주신 결합(`if d:`의 암묵적 의존)은 실재하며, Rev.2는 이를 **런타임 우회 + CI 검출** 두 겹으로 막습니다. [VERDICT: PASS] [AGREEMENT: REACHED]