# Area #3 — Visibility / Physical Presence SOT v1 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** `shot_visibility` regex/lexicon 의 `visible_entity_ids` decision/blocking 권한 박탈. `shot_director` prompt closed pattern bundle 4 pattern 제거 (Korean grammar example). `scene_context_loader.detect_offscreen_drift` 를 Path 1 (structured, blocking) / Path 2 (proximity NL diagnostic, no blocking) 함수 split. canary verify (S12_Shot4 / S12_Shot13 / S19). schema 신설 = deferred by policy (post-closure trigger 시 separate follow-up area).

**Architecture:** 4-layer (Producer prompt rewrite / Helper module function split / Consumer mutation 권한 박탈 + caller 분기 / Test cascade). `shot_director` prompt v6 = 추상 원칙 + synthetic 캐릭터 예시 (Korean grammar example 0). `shot_director.py:182-201` mutation 폐기 + audit field 유지 + comment. `shot_visibility.py` 2 함수 신설 (`detect_offscreen_drift_structured` hybrid gate, `detect_offscreen_drift_proximity_diagnostic` no blocking) + 기존 `detect_offscreen_drift` 삭제 (deprecated wrapper 금지). `scene_context_loader._assert_no_visible_staging_drift` caller 분기 (Path 1 raise, Path 2 logger.warning).

**Tech Stack:** Python 3 (FastAPI backend), pytest, prompt_loader (stem-independent latest pick), `_config_hash` (schema_version + prompt_version sha256), step_runner cp_mismatch trigger.

**Spec reference:** `docs/superpowers/specs/2026-05-17-area-3-visibility-physical-presence-sot-v1-design.md` (commit `32210e5`).

---

## Wave Ordering + Dependencies

```
W0 (Measurement, W2 gate) ─→ W1 (prompt v6)
                          └─→ W2 (mutation 박탈)
W1 (prompt v6) ─┬─→ W4 (closure)
                └─ (W2/W3 와 무관, parallel 가능)
W2 (mutation 박탈) ─→ W4 (closure)
W3 (함수 split + caller 분기) ─→ W4 (closure)
W4 (canary + residue + closure)
```

각 wave = atomic commit (wave 안 모든 변경 한 commit). W1/W2/W3 는 W0 산출물 review 후 진행 의무 (W0 evidence that mutation removal is demonstrably unsafe → v1 stop / re-scope). W4 = closure + roadmap amend.

---

## File Structure

**Files to create**:
- `prompts/_base/shot_director/6.YYYYMMDDHHMM/system.md` (W1)
- `prompts/_base/shot_director/6.YYYYMMDDHHMM/analyze.md` (W1, v5 verbatim copy)
- `prompts/_base/shot_director/6.YYYYMMDDHHMM/analyze_schema.json` (W1, v5 verbatim copy)
- `backend/tests/_audit_outputs/area_3_w0/mutation_inventory.jsonl` (W0, run-local — conditional .gitignore 검토)
- `docs/superpowers/specs/2026-05-17-area-3-w0-measurement.md` (W0)
- `backend/tests/unit/test_shot_visibility_drift_structured.py` (W3)
- `backend/tests/unit/test_shot_visibility_proximity_diagnostic.py` (W3)
- `backend/tests/unit/test_shot_director_no_mutation.py` (W2)
- `backend/tests/test_active_prompt_residue_shot_director_v6.py` (W1)
- `backend/tests/test_residue_shot_director_mutation_pattern.py` (W2)
- `backend/tests/test_residue_detect_offscreen_drift_legacy.py` (W3)
- `backend/tests/test_canary_area_3_focused_integration.py` (W4)

**Files to modify**:
- `backend/app/core/steps/shot_director_step.py:30` (W1 `SHOT_DIRECTOR_PROMPT_VERSION` 갱신)
- `backend/app/modules/pipeline/shot_director.py:182-201` (W2 mutation 제거 + comment + warning)
- `backend/app/modules/pipeline/shot_visibility.py` (W3 함수 split + 기존 삭제 + docstring 정확화)
- `backend/app/core/steps/scene_context_loader.py:443-478` (W3 caller 분기)
- `docs/superpowers/specs/2026-05-16-track-b-semantic-debt-roadmap-design.md` (W4 §5.3 + §11 update)
- `/Users/manta/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/MEMORY.md` + closure 종합 메모 (W4)

---

## W0: Mutation impact measurement (W2 진입 gate)

W0 목적: pre-W2 baseline (3 input: 사례 inventory + W1 input + W2 baseline). cp-only + 3 canary instrumented replay (Option B). schema 자동 진입 X (Q1 강제).

**Files:**
- Create: `backend/tests/_audit_outputs/area_3_w0/mutation_inventory.jsonl`
- Create: `docs/superpowers/specs/2026-05-17-area-3-w0-measurement.md`
- Temporary instrumentation (W0-only, not committed): `backend/app/modules/pipeline/shot_director.py` ad-hoc `ve_before` log

### W0.1: cp 기반 inventory 작성 (3 canary fixture)

- [ ] **Step 1**: Locate canary cp fixtures (S12_Shot4 / S12_Shot13 / S19)

Run: 
```bash
find backend/tests -name "*S12*" -o -name "*S19*" 2>/dev/null | grep -i shot_director | head -5
```
Expected: cp fixture path(s) 출력. 없으면 production cp `backend/db.sqlite` 안 shot_director step output 조회 의무.

- [ ] **Step 2**: cp 안 `visible_entity_ids` + `excluded_offscreen_entity_ids` + shot_index/shot_description extract script 작성 (one-shot, not committed)

작성 위치: `/tmp/area_3_w0_cp_extract.py` (run-local):
```python
"""Area #3 W0 — cp 기반 inventory extract (run-local, not committed)."""
import json
import sys
from pathlib import Path

CANARY_SCENES = [
    ("S12", 4),   # close-up gaze pattern
    ("S12", 13),  # body-part possession false-positive guard
    ("S19", None),  # directional / structured-path interaction
]

def extract_from_cp(cp_path: Path) -> list[dict]:
    """shot_director cp output 안 (scene_index, shot_index, visible, excluded, description)
    extract."""
    cp = json.loads(cp_path.read_text())
    scenes = cp.get("data", {}).get("scenes", [])
    rows = []
    for sc in scenes:
        si = sc["scene_index"]
        for sh in sc.get("shots", []):
            rows.append({
                "scene_index": si,
                "shot_index": sh["shot_index"],
                "shot_description": sh.get("description", ""),
                "lexicon_candidates": sh.get("excluded_offscreen_entity_ids", []),
                "post_mutation_visible": sh.get("visible_entity_ids", []),
            })
    return rows


if __name__ == "__main__":
    cp_path = Path(sys.argv[1])
    rows = extract_from_cp(cp_path)
    # canary filter — sc_key match AND (sh is None means all shots in that scene)
    for r in rows:
        sc_key = f"S{r['scene_index']}"
        if not any(
            sc_key == s and (sh is None or r['shot_index'] == sh)
            for s, sh in CANARY_SCENES
        ):
            continue
        print(json.dumps(r, ensure_ascii=False))
```

- [ ] **Step 3**: Run extract + verify canary records 발견

Run:
```bash
python /tmp/area_3_w0_cp_extract.py <canary_cp_path>
```
Expected: 3 canary fixture records (S12_Shot4 / S12_Shot13 / S19) 출력. 없으면 cp_path 정정.

- [ ] **Step 4**: Records 안 `lexicon_matched_pattern` 분류 (manual review — gaze_verb_framing / directional_close_up / body_part_possession_skipped / 기타)

manual 분류 후 jsonl 작성:
```bash
mkdir -p backend/tests/_audit_outputs/area_3_w0/
```
파일 안에 step 3 출력 + `lexicon_matched_pattern` field 추가 + `agreement: "no_match_control"` or `"lexicon_candidate_expected_exclude"` 분류 + `raw_*: null` (replay 전):
```jsonl
{"scene_index": 12, "shot_index": 4, "shot_description": "...", "lexicon_candidates": ["C##"], "lexicon_matched_pattern": "gaze_verb_framing", "post_mutation_visible": ["C##"], "raw_llm_visible_entity_ids": null, "raw_source": "unavailable", "replay_status": "skipped", "agreement": "lexicon_candidate_expected_exclude", "case_source": "canary"}
```

### W0.2: Instrumented replay (3 canary fixture, W0-only)

- [ ] **Step 1 (pre-check)**: Verify `shot_director.py` clean before instrumentation (no unrelated uncommitted changes):

```bash
git status backend/app/modules/pipeline/shot_director.py
git diff --stat backend/app/modules/pipeline/shot_director.py
```
Expected: clean working tree (no diff). If diff exists, STOP and reconcile (user unrelated changes 확인 의무 — git checkout 금지).

- [ ] **Step 2**: Add ad-hoc instrumentation to `shot_director.py:182-201` (W0-only, NOT committed) via Edit tool (apply_patch semantics, not raw overwrite):

```python
# W0-only ad-hoc instrumentation — NOT to commit
for ls in llm_shots:
    desc = desc_by_idx.get(ls.get("shot_index"), "")
    excluded_map = detect_gaze_pattern_exclusions(desc, name_to_char_id)
    excluded_ids = set(excluded_map.keys())
    if excluded_ids:
        ve_before = list(ls.get("visible_entity_ids", []))
        # W0 instrumentation:
        import os, json
        log_path = os.environ.get("AREA_3_W0_LOG")
        if log_path:
            with open(log_path, "a") as fh:
                fh.write(json.dumps({
                    "scene_index": scene_index,
                    "shot_index": ls.get("shot_index"),
                    "ve_before": ve_before,
                    "excluded_ids": sorted(excluded_ids),
                }) + "\n")
        # ... 기존 mutation 그대로 유지 (W0 진행 중에는 production 동작)
        ls["visible_entity_ids"] = [
            sid for sid in ve_before if sid not in excluded_ids
        ]
        # ... (line 191-198 기존 로직)
    ls["excluded_offscreen_entity_ids"] = sorted(excluded_ids)
```

- [ ] **Step 2**: 3 canary fixture로 replay 실행

Run:
```bash
AREA_3_W0_LOG=/tmp/area_3_w0_replay.jsonl \
  pytest backend/tests/path/to/canary_fixture_test.py::test_S12_S19 -v
```
Expected: `/tmp/area_3_w0_replay.jsonl` 안 3 records (ve_before + excluded_ids).

- [ ] **Step 3**: replay 결과를 jsonl에 merge (`raw_llm_visible_entity_ids`, `raw_source: "instrumented_replay"`, `replay_status: "success"`)

```bash
# manual merge — W0.1 jsonl에 replay 결과 update
```
또는 cp-only fallback 의무 (replay 불가 시):
- `raw_source: "unavailable"`
- `replay_status: "blocked"` (LLM/API issue) or `"skipped"` (시도 안 함)
- W0 markdown 안 blocker section 기록

- [ ] **Step 4**: Revert instrumentation via **explicit reverse Edit** (NOT `git checkout` — unrelated 변경 보호):

Use Edit tool to reverse W0.2 Step 2 instrumentation. The reverse patch removes the instrumentation block added in Step 2 and restores original lines 182-201 verbatim:

```python
# Reverse Edit: remove instrumentation block, restore production code.
# original (before W0.2 Step 2):
for ls in llm_shots:
    desc = desc_by_idx.get(ls.get("shot_index"), "")
    excluded_map = detect_gaze_pattern_exclusions(desc, name_to_char_id)
    excluded_ids = set(excluded_map.keys())
    if excluded_ids:
        ve_before = list(ls.get("visible_entity_ids", []))
        ls["visible_entity_ids"] = [
            sid for sid in ve_before if sid not in excluded_ids
        ]
        removed = sorted(set(ve_before) & excluded_ids)
        if removed:
            logger.info(...)
    ls["excluded_offscreen_entity_ids"] = sorted(excluded_ids)
```

Then verify:
```bash
git diff --stat backend/app/modules/pipeline/shot_director.py
```
Expected: 0 lines changed (working tree clean for that file).

**금지**: `git checkout` 사용 X (사용자 unrelated 변경 보호).

### W0.3: Markdown document 작성 (4 section)

- [ ] **Step 1**: Create `docs/superpowers/specs/2026-05-17-area-3-w0-measurement.md` with 4 sections:

```markdown
# Area #3 W0 — Mutation Impact Measurement

## §1. Observed Mutation Cases

| Scene/Shot | lexicon_matched_pattern | Lexicon candidates | Raw LLM emit | Post-mutation visible | Agreement | Source |
|---|---|---|---|---|---|---|
| S12_Shot4 | gaze_verb_framing | [...] | [...] | [...] | ... | canary |
| S12_Shot13 | body_part_possession_skipped | [...] | [...] | [...] | ... | canary |
| S19 | directional_close_up | [...] | [...] | [...] | ... | canary |

## §2. Pattern Category → Abstract Prompt Principle

(W1 prompt rewrite input — Korean grammar example 제거 후 LLM 이 자체 판단할 수 있도록 추상 원칙 추출)

- gaze_verb_framing: "If shot description focuses on character A's face/eyes in close-up, and character B is the gaze target, B is likely off-frame and should be excluded from visible_entity_ids."
- body_part_possession_skipped: "X's body-part possession (X의 어깨/손/...) is descriptive of X's posture, not a gaze target."
- directional_close_up: "Directional phrase (X 쪽으로 / toward X) + close-up framing indicates X is the off-frame gaze target."

## §3. False-Positive / No-op Cases

(lexicon이 추출했지만 mutation 효과 없음 OR 실제 frame-internal case)

| Case | Pattern | Reason |
|---|---|---|

## §4. W2 Removal Risk Notes

(mutation 제거 시 회귀 가능 case + 안전 evidence)

- Risk: ...
- Evidence: ...
```

- [ ] **Step 2**: Commit W0 산출물 (markdown + jsonl)

```bash
# Check .gitignore for backend/tests/_audit_outputs/
git check-ignore backend/tests/_audit_outputs/area_3_w0/mutation_inventory.jsonl
```
- If ignored → jsonl run-local only, commit markdown only
- If tracked → both committed

```bash
git add docs/superpowers/specs/2026-05-17-area-3-w0-measurement.md
# Conditional add jsonl based on gitignore check
git commit -m "$(cat <<'EOF'
audit(area-3-visibility-physical-presence-sot-v1): W0 mutation impact measurement

3 canary fixture (S12_Shot4 / S12_Shot13 / S19) cp inventory + 
instrumented replay results (W0-only ad-hoc, not committed). 
W2 진입 gate verify: mutation removal demonstrable safety assessment.

W0 산출물:
- backend/tests/_audit_outputs/area_3_w0/mutation_inventory.jsonl (run-local 또는 committed)
- docs/superpowers/specs/2026-05-17-area-3-w0-measurement.md (4 section)

agreement enum distribution + W1 prompt rewrite 추상 원칙 추출 + 
W2 removal risk notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git status
```
Expected: working tree clean (단 `.wave_1b_timestamp.txt` 등 untracked 파일 unrelated).

### W0.4: W2 진입 gate review (사용자 명시 review 의무)

- [ ] **Step 1**: 사용자에게 W0 markdown 검토 + W2 진입 gate decision request:
  - **Pass**: W1/W2/W3 진입 OK
  - **Stop**: W0 evidence 가 mutation removal 위험성 명확 → v1 stop OR re-scope
  - **Schema 자동 진입 X** (Q1 강제 — `next_session_area_3_*` BLOCKING)

- [ ] **Step 2**: 사용자 명시 승인 후 W1 진입 의무.

---

## W1: shot_director prompt v6 rewrite

Korean grammar example 4 pattern 제거 + 추상 원칙 + synthetic 캐릭터 예시. SHOT_DIRECTOR_PROMPT_VERSION 갱신. analyze.md / analyze_schema.json 변경 없음 (verbatim copy).

**Files:**
- Create: `prompts/_base/shot_director/6.YYYYMMDDHHMM/system.md`
- Create: `prompts/_base/shot_director/6.YYYYMMDDHHMM/analyze.md` (v5 verbatim copy)
- Create: `prompts/_base/shot_director/6.YYYYMMDDHHMM/analyze_schema.json` (v5 verbatim copy)
- Create: `backend/tests/test_active_prompt_residue_shot_director_v6.py`
- Modify: `backend/app/core/steps/shot_director_step.py:30`

### W1.1: Write failing residue gate test

- [ ] **Step 1**: Create `backend/tests/test_active_prompt_residue_shot_director_v6.py`:

```python
"""Area #3 W1 — active shot_director prompt residue gate.

7 exact substring literals MUST be absent from the latest active prompt 
(grep substring match, not regex execution). Generic Korean particles 
(를/을/의) 자체는 gate 대상 X — false positive 회피.

Scope: prompts/_base/shot_director/<latest>/system.md only.
Legacy archive prompts/_base/shot_director/[1-5].*/ 제외.
"""
from pathlib import Path

import pytest

from app.modules.prompt_loader import _latest_version_dir


REPO_ROOT = Path(__file__).resolve().parents[2]
SHOT_DIRECTOR_PROMPT_BASE = REPO_ROOT / "prompts" / "_base" / "shot_director"

# 7 exact substring literals to ban (Codex iter 1 confirmation)
BANNED_LITERALS = (
    "X[를을]",
    "Y[의]",
    "Gaze-target close-up 패턴",
    "명시적 off-camera/off-screen phrase",
    "차단(blocking) 패턴",
    "Reaction-only 패턴",
    "(차단|막다|가리다|block|obstruct)",
)


def _read_active_system_md() -> tuple[Path, str]:
    latest = _latest_version_dir(SHOT_DIRECTOR_PROMPT_BASE)
    system_md = latest / "system.md"
    return system_md, system_md.read_text(encoding="utf-8")


@pytest.mark.parametrize("literal", BANNED_LITERALS)
def test_active_prompt_no_banned_literal(literal: str):
    """7 banned literals MUST be absent from active shot_director prompt."""
    path, content = _read_active_system_md()
    assert literal not in content, (
        f"Banned literal {literal!r} found in active prompt {path}. "
        f"Area #3 W1 prompt v6 rewrite required."
    )


def test_active_prompt_is_v6_or_later():
    """Active shot_director prompt must be v6 or later (no Korean grammar bundle)."""
    latest = _latest_version_dir(SHOT_DIRECTOR_PROMPT_BASE)
    major = int(latest.name.split(".")[0])
    assert major >= 6, (
        f"Active shot_director prompt {latest.name} < v6. "
        f"Area #3 W1 requires prompt v6+."
    )
```

- [ ] **Step 2**: Run test to verify FAIL (active v5 has banned literals)

Run:
```bash
pytest backend/tests/test_active_prompt_residue_shot_director_v6.py -v
```
Expected: FAIL — 7 banned literals 모두 v5 system.md:29-49에 존재.

### W1.2: Create prompt v6 directory + files

- [ ] **Step 1**: Determine timestamp:

Run:
```bash
date "+%Y%m%d%H%M"
```
Expected: 12-digit timestamp (예: `202605171800`). 이를 prompt v6 directory name에 사용.

- [ ] **Step 2**: Create v6 directory:

```bash
TS=$(date "+%Y%m%d%H%M")
mkdir -p prompts/_base/shot_director/6.${TS}/
```

- [ ] **Step 3**: Copy `analyze.md` and `analyze_schema.json` verbatim:

```bash
cp prompts/_base/shot_director/5.202605131800/analyze.md prompts/_base/shot_director/6.${TS}/analyze.md
cp prompts/_base/shot_director/5.202605131800/analyze_schema.json prompts/_base/shot_director/6.${TS}/analyze_schema.json
```

- [ ] **Step 4**: Write new `system.md` (Korean grammar example 제거 + 추상 원칙):

`prompts/_base/shot_director/6.${TS}/system.md`:
```markdown
# Shot별 등장 요소 판정 (변형 형태 확정)

씬 내에서 shot 순서에 따라 각 shot 에 **카메라 프레임 안에 물리적으로 보이는** 요소를 결정합니다.

## Frame Visibility SOT

Decide visible_entity_ids from the shot's frame meaning,
not from fixed grammar patterns or phrase lists.
Do not rely on Korean particles, fixed off-camera phrases,
or closed examples.
A mentioned entity is visible only when it is physically
inside the camera frame.

## 핵심 원칙

- **visible_entity_ids = 카메라 프레임 안에 물리적으로 보이는 entity SOT.**
- 단순히 씬 안에 존재하거나 beat 와 관련 있다고 해서 포함하지 않습니다.
- 프레임 밖에 있는 entity 는 (씬 맥락상 관련 있더라도) 제외합니다.
- character / location / prop **모두 동일 기준** — 프레임 안 가시성.

## 핵심 규칙

### 일반 요소 (변형 아닌 것)
- 해당 shot 의 설명(description)과 등장 인물(characters)을 기준으로 판단
- shot 에 언급되지 않은 캐릭터는 visible 에서 제외
- **단, 시신/무의식/부상/잠든 인물이 카메라에 보이면 반드시 포함** (살아있든 죽었든 무관)

### 배경(L) / 소품(P) — frame-visible 만 포함

- shot description 에 **언급되었다는 이유만으로** 포함하지 **않는다**.
- **카메라 프레임 안에 실제로 보이는 경우에만** visible_entity_ids 에 포함.
- **ECU / CU / macro shot** 에서 프레임 밖에 있는 scene-relevant prop / location 은 제외.
- **WS / MS / two-shot** 등 넓은 framing 에서는 frame 에 보이는 L/P 모두 포함.
- 판단 기준은 항상 "이 shot 의 카메라가 실제로 잡는 것" — scene 상위 맥락 아님.

### Off-camera / Gaze target 제외 (중요)

description 안에 등장하지만 **카메라 프레임 밖**에 있는 인물은 visible_entity_ids 에서 **반드시 제외**합니다. 다음 의미적 기준으로 판단:

- **Gaze target close-up**: shot 의 framing 이 인물 A 의 얼굴/표정/시선/뒷모습 close-up 이면서, A 가 다른 인물 B 를 보고 있다고 묘사된 경우 — B 는 시선의 대상으로 **frame 밖** 가능성이 높음. A 만 visible 에 포함.

- **Off-camera 명시**: description 또는 staging 단서에 인물이 frame 밖 / off-camera / off-screen 임이 명시되어 있으면 그 인물 제외 (언어 무관, 의미적 판단).

- **차단(blocking)**: 한 인물이 다른 인물을 차단하여 보이지 않게 한다고 명시되면, 차단된 인물 제외.

- **Reaction-only**: description 이 한 인물의 표정/시선 close-up 만 잡고 다른 인물은 그 인물에 대한 반응 trigger 로만 언급되면, 반응을 부른 인물은 frame 밖 — 제외.

- **Body-part / posture guard (false-positive guard)**: 한 인물에게 속한 신체 부위(어깨/손/팔/얼굴/등/허리 등) 또는 그 인물의 자세/상태가 다른 인물의 시선/접촉/지지 동작과 함께 묘사된 경우, 그 신체 부위를 off-frame gaze target 으로 해석하지 **않습니다**. description 이 두 인물을 같은 frame 안의 shared posture/contact moment 로 구성하면, 명시적 off-frame 표시가 없는 한 two-shot 으로 간주하고 두 인물 모두 visible_entity_ids 에 **반드시 포함**합니다. 이 guard 는 위 모든 off-frame exclusion rule 보다 먼저 적용합니다.

판단 기준: 한국어 문법 패턴 / 고정 phrase list / 닫힌 예시 의존 X. shot description 의 frame meaning 으로 의미적 판단.

### 추상 case 예시 (문장형 한국어 grammar example 0, 언어 중립 추상 case)

- 예: Frame shows Character A in close-up of face/eyes; Character B is described only as A's gaze target → include A in visible_entity_ids, exclude B (B is off-frame gaze target).
- 예: One character (the blocker) physically blocks another character from camera view → include blocker, exclude blocked character (blocked is off-frame from camera).
- 예: Frame shows one character's reaction-only close-up; another character is mentioned only as the trigger of that reaction → include reactor, exclude trigger character (trigger is off-frame).
- 예: A character is mentioned with explicit off-camera/off-screen indication → exclude that character regardless of mention.
- 예: Frame shows Character A and Character B in a shared posture/contact moment; Character A's own body part or posture is touched, held, or supported while Character B looks at or supports A → treat it as a two-shot and include both A and B unless one is explicitly off-frame. Do not exclude A merely because A's body part appears near a gaze/action phrase.

### 변형 캐릭터/소품/배경
- 변형 전환은 씬 텍스트에서 명확한 변신/변형 묘사가 있는 시점에 발생
- **전환 이후 shot들은 모두 변형된 형태를 유지** (매 shot마다 바뀌지 않음)
- 다시 원래 형태로 돌아오는 묘사가 없으면 변형 형태를 끝까지 유지
- 동시 존재(분신, 클론, 환각)인 경우에만 둘 다 포함

### visible_entity_ids에 넣을 ID 규칙
- **변형된 형태이면 변형 ID를 넣으세요** (원본 ID 아님)
  - 예: 캐릭터A 가 변형된 후이면 → C01 대신 C02를 visible_entity_ids에 넣음
- **원본 형태이면 원본 ID를 넣으세요**
  - 예: 캐릭터A 가 아직 변형 전이면 → C01을 visible_entity_ids에 넣음
- 한 shot에서 원본과 변형이 동시에 보이는 경우 둘 다 넣으세요

### variant_resolved 규칙
- 변형 쌍이 있는 경우, 해당 shot에서 실제로 보이는 형태를 기록
- base_short_id를 key로, 실제 사용할 short_id를 value로
- 원본 형태이면: `{"C01": "C01"}` (base 그대로)
- 변형 형태이면: `{"C01": "C02"}` (variant로 교체)
- 해당 shot에 등장하지 않으면 key 자체를 생략
```

(주의: 위 system.md 안 7 banned literals 0 hits 의무. "캐릭터A/B/C/D" synthetic 예시만 사용, 작품 고유명사 0)

- [ ] **Step 5**: Verify v6 system.md banned literals 0:

```bash
TS=$(ls prompts/_base/shot_director/ | grep "^6\." | sort | tail -1 | sed 's/^6\.//')
grep -Fc "X[를을]" prompts/_base/shot_director/6.${TS}/system.md  # expected: 0
grep -Fc "Y[의]" prompts/_base/shot_director/6.${TS}/system.md   # expected: 0
grep -Fc "Gaze-target close-up 패턴" prompts/_base/shot_director/6.${TS}/system.md  # expected: 0
```
Expected: 모두 0.

### W1.3: Update SHOT_DIRECTOR_PROMPT_VERSION

- [ ] **Step 1**: Read current value:

```bash
grep "SHOT_DIRECTOR_PROMPT_VERSION" backend/app/core/steps/shot_director_step.py
```
Expected: `SHOT_DIRECTOR_PROMPT_VERSION = "5.202605131800"`

- [ ] **Step 2**: Update to v6 (with the timestamp from W1.2 Step 1):

`backend/app/core/steps/shot_director_step.py:30` 변경 (Edit tool):
```python
# Prompt pack version — v6 는 Korean grammar example 4 pattern bundle 제거.
# Area #3 v1: visibility/physical presence SOT — 추상 원칙 + synthetic
# 캐릭터 (캐릭터A/B/C/D) 예시 + Korean grammar example 0.
# bump 시 _config_hash 변동 → step_runner P0-3 stale cp 자동 감지.
SHOT_DIRECTOR_PROMPT_VERSION = "6.YYYYMMDDHHMM"
```

(`"6.YYYYMMDDHHMM"` 는 W1.2 Step 1 timestamp 사용)

### W1.4: Verify residue gate passes

- [ ] **Step 1**: Re-run residue gate test:

```bash
pytest backend/tests/test_active_prompt_residue_shot_director_v6.py -v
```
Expected: ALL PASS (7 banned literals 모두 0 + active prompt major == 6).

### W1.5: Commit W1

- [ ] **Step 1**: Stage + commit:

```bash
TS=$(ls prompts/_base/shot_director/ | grep "^6\." | sort | tail -1 | sed 's/^6\.//')
git add prompts/_base/shot_director/6.${TS}/
git add backend/app/core/steps/shot_director_step.py
git add backend/tests/test_active_prompt_residue_shot_director_v6.py
git commit -m "$(cat <<EOF
prompt(area-3-visibility-physical-presence-sot-v1): W1 — shot_director v6 (Korean grammar example bundle 제거)

prompt v5 (5.202605131800) → v6 (6.${TS}) — system.md:29-49 closed
pattern bundle 4 pattern 제거 (gaze-target / off-camera phrase / 
blocking / reaction-only). Korean grammar example (X[를을], Y[의], 
한국어 동사 활용) 제거. 추상 원칙 (Frame Visibility SOT) + synthetic 
캐릭터 (캐릭터A/B/C/D) 예시 only.

Area #3 spec §2.3 / §4.2 verbatim. multilingual non-goal (Q3): Goal=
Korean grammar 제거, side effect=neutrality, non-goal=verified 
multilingual support.

analyze.md / analyze_schema.json v5 verbatim copy (SCHEMA_VERSION 유지).
SHOT_DIRECTOR_PROMPT_VERSION = "6.${TS}" 갱신 (shot_director_step.py:30).
_config_hash 자동 변동 → step_runner cp_mismatch trigger.

residue gate (test_active_prompt_residue_shot_director_v6.py):
- 7 banned substring literal 0 hits in active prompt
- active prompt major >= 6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git status
```
Expected: working tree clean (W1 변경 모두 committed).

---

## W2: shot_director.py mutation 권한 박탈

`detect_gaze_pattern_exclusions` 호출 유지 (audit field emit). `visible_entity_ids` 재구성 제거 (line 187-191). mismatch 시 `logger.warning` only.

**Files:**
- Create: `backend/tests/unit/test_shot_director_no_mutation.py`
- Create: `backend/tests/test_residue_shot_director_mutation_pattern.py`
- Modify: `backend/app/modules/pipeline/shot_director.py:182-201`

### W2.1: Write failing no-mutation unit test

- [ ] **Step 1**: Create `backend/tests/unit/test_shot_director_no_mutation.py`:

```python
"""Area #3 W2 — shot_director.py mutation 권한 박탈 unit test.

LLM emit visible_entity_ids 가 detect_gaze_pattern_exclusions 결과로 
mutate되지 않음을 verify. audit field (excluded_offscreen_entity_ids) 
는 그대로 emit. mismatch 시 logger.warning 만.
"""
from unittest.mock import patch

import pytest


def _make_llm_shots():
    return [
        {
            "shot_index": 1,
            "visible_entity_ids": ["C01", "C02"],
            "variant_resolved": None,
        }
    ]


@patch("app.modules.pipeline.shot_director.call_structured")
@patch("app.modules.pipeline.shot_director.detect_gaze_pattern_exclusions")
def test_no_mutation_when_lexicon_candidates_present(
    mock_exclusions, mock_call,
):
    """LLM emit visible == [C01, C02] + lexicon candidates == {C02: 'name'} 
    → post-call visible == [C01, C02] (mutation 없음).
    """
    from app.modules.pipeline.shot_director import _resolve_scene_llm

    mock_call.return_value = {"shots": _make_llm_shots()}
    mock_exclusions.return_value = {"C02": "캐릭터B"}

    result = _resolve_scene_llm(
        scene_index=12,
        scene_ve=["C01", "C02"],
        shots=[{"shot_index": 1, "description": "..."}],
        scene_text="",
        variant_map={},
        entity_name_map={"C01": "캐릭터A", "C02": "캐릭터B"},
        entity_desc_map={"C01": "", "C02": ""},
    )

    assert len(result) == 1
    assert result[0]["visible_entity_ids"] == ["C01", "C02"]  # mutation 없음
    assert result[0]["excluded_offscreen_entity_ids"] == ["C02"]  # audit 유지


@patch("app.modules.pipeline.shot_director.call_structured")
@patch("app.modules.pipeline.shot_director.detect_gaze_pattern_exclusions")
@patch("app.modules.pipeline.shot_director.logger")
def test_logger_warning_on_mismatch(
    mock_logger, mock_exclusions, mock_call,
):
    """LLM emit visible 와 lexicon candidates 교집합이 있으면 logger.warning emit."""
    from app.modules.pipeline.shot_director import _resolve_scene_llm

    mock_call.return_value = {"shots": _make_llm_shots()}
    mock_exclusions.return_value = {"C02": "캐릭터B"}  # C02 in LLM emit

    _resolve_scene_llm(
        scene_index=12,
        scene_ve=["C01", "C02"],
        shots=[{"shot_index": 1, "description": "..."}],
        scene_text="",
        variant_map={},
        entity_name_map={"C01": "캐릭터A", "C02": "캐릭터B"},
        entity_desc_map={"C01": "", "C02": ""},
    )

    assert mock_logger.warning.called
    args, _ = mock_logger.warning.call_args
    assert "diagnostic only, no mutation" in args[0] or "no mutation" in str(args)
```

- [ ] **Step 2**: Run test to verify FAIL (현재 mutation 코드가 visible_entity_ids 변조):

```bash
pytest backend/tests/unit/test_shot_director_no_mutation.py -v
```
Expected: `test_no_mutation_when_lexicon_candidates_present` FAIL — 현재 코드는 mutation 적용 후 visible == ["C01"] (C02 제거).

### W2.2: Modify shot_director.py:182-201

- [ ] **Step 1**: Read current code block:

```bash
sed -n '180,202p' backend/app/modules/pipeline/shot_director.py
```

- [ ] **Step 2**: Replace lines 182-199 with mutation-removed version:

`backend/app/modules/pipeline/shot_director.py` Edit (lines 182-201):

```python
    desc_by_idx = {sh["shot_index"]: sh.get("description", "") for sh in shots}
    for ls in llm_shots:
        desc = desc_by_idx.get(ls.get("shot_index"), "")
        excluded_map = detect_gaze_pattern_exclusions(desc, name_to_char_id)
        excluded_ids = set(excluded_map.keys())

        # Area #3 W2: mutation 권한 박탈 — visible_entity_ids 재구성 제거.
        # LLM emit 결과를 SOT 로 유지. lexicon candidates 와 mismatch 시
        # logger.warning only (no mutation, no raise).
        if excluded_ids:
            ve_set = set(ls.get("visible_entity_ids", []))
            mismatch = ve_set & excluded_ids
            if mismatch:
                logger.warning(
                    "shot_director S%d_Shot%s: LLM emitted visible_entity_ids "
                    "%s intersect lexicon diagnostic candidates %s "
                    "(Area #3 diagnostic only, no mutation)",
                    scene_index, ls.get("shot_index"),
                    sorted(mismatch),
                    [f"{sid}({excluded_map[sid]})" for sid in sorted(mismatch)],
                )

        # Area #3: diagnostic only. This field no longer mutates visible_entity_ids.
        # Historical name kept for checkpoint/debug compatibility.
        ls["excluded_offscreen_entity_ids"] = sorted(excluded_ids)

    return llm_shots
```

### W2.3: Verify no-mutation test passes

- [ ] **Step 1**: Re-run W2.1 test:

```bash
pytest backend/tests/unit/test_shot_director_no_mutation.py -v
```
Expected: ALL PASS.

### W2.4: Write residue gate test (mutation pattern grep)

- [ ] **Step 1**: Create `backend/tests/test_residue_shot_director_mutation_pattern.py`:

```python
"""Area #3 W2 — shot_director.py mutation pattern residue gate.

`ls["visible_entity_ids"] =` 형식 mutation pattern 이 production code 
안에서 0 hits 여야 한다 (Area #3 W2 closure 후).

Scope: backend/app/modules/pipeline/shot_director.py.
"""
import re
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parents[2]
SHOT_DIRECTOR_PY = REPO_ROOT / "backend" / "app" / "modules" / "pipeline" / "shot_director.py"


# Match `ls["visible_entity_ids"] = ...` (assignment), 
# but NOT `ls.get("visible_entity_ids", ...)` (read).
MUTATION_PATTERN = re.compile(r'ls\["visible_entity_ids"\]\s*=')


def test_no_visible_entity_ids_mutation_in_shot_director():
    """Area #3 W2 closure: visible_entity_ids assignment pattern 0 hits."""
    content = SHOT_DIRECTOR_PY.read_text(encoding="utf-8")
    matches = MUTATION_PATTERN.findall(content)
    assert len(matches) == 0, (
        f"Found {len(matches)} mutation pattern(s) in {SHOT_DIRECTOR_PY}. "
        f"Area #3 W2 requires mutation 권한 박탈."
    )
```

- [ ] **Step 2**: Run test to verify PASS (W2.2 변경 후 0 hits):

```bash
pytest backend/tests/test_residue_shot_director_mutation_pattern.py -v
```
Expected: PASS.

### W2.5: Commit W2

- [ ] **Step 1**: Stage + commit:

```bash
git add backend/app/modules/pipeline/shot_director.py
git add backend/tests/unit/test_shot_director_no_mutation.py
git add backend/tests/test_residue_shot_director_mutation_pattern.py
git commit -m "$(cat <<'EOF'
fix(area-3-visibility-physical-presence-sot-v1): W2 — shot_director.py mutation 권한 박탈

shot_director.py:182-201 변경. detect_gaze_pattern_exclusions 호출 
유지 (audit field emit) — 단 visible_entity_ids 재구성 (line 187-191) 
제거. LLM emit 결과 SOT 유지. lexicon candidates 와 mismatch 시 
logger.warning only (no mutation, no raise).

excluded_offscreen_entity_ids audit field 그대로 emit + comment 
"Area #3: diagnostic only. Historical name kept for checkpoint/debug 
compatibility." (Q4 Option B — rename 미실시).

Area #3 spec §4.3 + §5.1 (W2) verbatim.

Tests:
- backend/tests/unit/test_shot_director_no_mutation.py: 
  no-mutation unit test (monkeypatch detect_gaze_pattern_exclusions + 
  call_structured) + logger.warning emission test
- backend/tests/test_residue_shot_director_mutation_pattern.py: 
  ls["visible_entity_ids"] = mutation pattern 0 hits residue gate

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git status
```
Expected: working tree clean.

---

## W3: scene_context_loader Path 1/2 함수 분리

`detect_offscreen_drift` 단일 함수 삭제. 2 함수 신설: `detect_offscreen_drift_structured` (Path 1 hybrid gate blocking) + `detect_offscreen_drift_proximity_diagnostic` (Path 2 no blocking). caller `scene_context_loader._assert_no_visible_staging_drift` 분기 (Path 1 raise, Path 2 logger.warning).

**Worker production-code rule (BLOCKING)**: production code body / docstring / comment 안 legacy single-function literal (`detect_offscreen_drift` not followed by `_structured` / `_proximity_diagnostic`) 0 의무. 묘사가 필요하면 "이전 merged drift helper", "Path 1/2 split 이전 단일 함수" 같은 표현 사용. Plan body / spec body / commit message body 안 legacy literal 언급은 historical reference 로 허용 (residue test scope 외).

**Files:**
- Create: `backend/tests/unit/test_shot_visibility_drift_structured.py`
- Create: `backend/tests/unit/test_shot_visibility_proximity_diagnostic.py`
- Create: `backend/tests/test_residue_detect_offscreen_drift_legacy.py`
- Modify: `backend/app/modules/pipeline/shot_visibility.py` (함수 split + 기존 삭제 + docstring)
- Modify: `backend/app/core/steps/scene_context_loader.py:443-478`

### W3.1: Write failing structured unit tests (4 cases)

- [ ] **Step 1**: Create `backend/tests/unit/test_shot_visibility_drift_structured.py`:

```python
"""Area #3 W3 — detect_offscreen_drift_structured Path 1 unit tests.

hybrid gate: camera_direction OFFSCREEN_RE gate + structured 
character_angles[].gaze_target_id resolution. Frame-internal mutual 
gaze 정상 (drift 아님). structured path는 name matching 안 함 
(gaze_target_id resolve only).
"""
import pytest

from app.modules.pipeline.shot_visibility import detect_offscreen_drift_structured


class TestCaseAFrameInternalMutualGaze:
    """Frame-internal mutual gaze 정상 — drift 아님 (CRITICAL false-positive guard)."""

    def test_no_drift_when_camera_direction_lacks_offscreen_phrase(self):
        result = detect_offscreen_drift_structured(
            visible_ids=["C01", "C02"],
            camera_direction="two-shot, mid distance, both characters visible",
            character_angles=[
                {
                    "character": "캐릭터A",
                    "gaze_direction_kind": "looks_at_character",
                    "gaze_target_id": "C02",
                },
            ],
            id_to_name={"C01": "캐릭터A", "C02": "캐릭터B"},
        )
        assert result == {}, (
            "OFFSCREEN_RE gate 미통과 → drift 아님 (frame-internal mutual gaze 정상)"
        )


class TestCaseBHybridGateBlocking:
    """OFFSCREEN_RE gate + structured matching → blocking candidate."""

    def test_drift_detected_when_offscreen_phrase_and_structured_match(self):
        result = detect_offscreen_drift_structured(
            visible_ids=["C01", "C02"],
            camera_direction="캐릭터A is in frame while 캐릭터B remains off-screen.",
            character_angles=[
                {
                    "character": "캐릭터A",
                    "gaze_direction_kind": "looks_at_character",
                    "gaze_target_id": "C02",
                },
            ],
            id_to_name={"C01": "캐릭터A", "C02": "캐릭터B"},
        )
        assert result == {"C02": "캐릭터B"}, (
            "OFFSCREEN_RE 매치 + gaze_target_id resolve → blocking candidate"
        )


class TestCaseCStructuredPathDoesNotMatchNames:
    """Structured path는 name matching 안 함 — gaze_target_id resolve only."""

    def test_drift_detected_via_structured_resolution_only(self):
        """camera_direction에 캐릭터B 이름이 없어도 gaze_target_id로 resolve."""
        result = detect_offscreen_drift_structured(
            visible_ids=["C01", "C02"],
            camera_direction="캐릭터A looks off-screen toward the unseen person.",
            character_angles=[
                {
                    "character": "캐릭터A",
                    "gaze_direction_kind": "looks_at_character",
                    "gaze_target_id": "C02",
                },
            ],
            id_to_name={"C01": "캐릭터A", "C02": "캐릭터B"},
        )
        assert result == {"C02": "캐릭터B"}, (
            "OFFSCREEN_RE = gate only, gaze_target_id 가 structured resolution"
        )


class TestCaseDInFrameGuarantee:
    """character_angles[].character ↔ visible canonical_name 매칭 sid 영구 제외."""

    def test_in_frame_sid_excluded_from_drift(self):
        """캐릭터A 가 character_angles 안 character로 명시 → visible C01 영구 제외."""
        result = detect_offscreen_drift_structured(
            visible_ids=["C01"],
            camera_direction="캐릭터A looks off-screen.",
            character_angles=[
                {
                    "character": "캐릭터A",
                    "gaze_direction_kind": "looks_at_character",
                    "gaze_target_id": "C01",  # gaze_target_id 가 visible 안에 있어도
                },
            ],
            id_to_name={"C01": "캐릭터A"},
        )
        # 캐릭터A 가 staging 안 character == visible canonical_name → in-frame guarantee
        assert result == {}, (
            "in_frame_guarantee: character_angles[].character == visible name → 영구 제외"
        )


class TestEmptyInputs:
    def test_empty_camera_direction_returns_empty(self):
        result = detect_offscreen_drift_structured(
            visible_ids=["C01"],
            camera_direction="",
            character_angles=[],
            id_to_name={"C01": "캐릭터A"},
        )
        assert result == {}

    def test_no_offscreen_phrase_returns_empty(self):
        result = detect_offscreen_drift_structured(
            visible_ids=["C01"],
            camera_direction="standard mid-shot",
            character_angles=[
                {
                    "character": "캐릭터A",
                    "gaze_direction_kind": "looks_at_character",
                    "gaze_target_id": "C02",
                },
            ],
            id_to_name={"C01": "캐릭터A", "C02": "캐릭터B"},
        )
        assert result == {}
```

- [ ] **Step 2**: Run test to verify FAIL (함수 미정의):

```bash
pytest backend/tests/unit/test_shot_visibility_drift_structured.py -v
```
Expected: FAIL — `ImportError: cannot import name 'detect_offscreen_drift_structured'`.

### W3.2: Write failing proximity_diagnostic unit tests

- [ ] **Step 1**: Create `backend/tests/unit/test_shot_visibility_proximity_diagnostic.py`:

```python
"""Area #3 W3 — detect_offscreen_drift_proximity_diagnostic Path 2 unit tests.

Path 2 = camera_direction OFFSCREEN_RE gate + proximity NL fallback. 
Diagnostic only (no raise). character_angles (optional) for in_frame_guarantee.
"""
import pytest

from app.modules.pipeline.shot_visibility import (
    detect_offscreen_drift_proximity_diagnostic,
)


class TestProximityNLFallback:
    def test_proximity_match_returns_drift_candidate(self):
        """OFFSCREEN_RE 매치 + proximity 윈도우 안 character name → candidate."""
        result = detect_offscreen_drift_proximity_diagnostic(
            visible_ids=["C01"],
            camera_direction="캐릭터A is off-camera in this shot.",
            id_to_name={"C01": "캐릭터A"},
        )
        # 캐릭터A 는 visible 안에 있지만 offscreen phrase 인접 → drift candidate
        assert result == {"C01": "캐릭터A"}, (
            "proximity NL: offscreen phrase 직전 윈도우 안 character name → drift"
        )


class TestInFrameGuaranteeOptional:
    def test_in_frame_excludes_proximity_candidate(self):
        """character_angles[].character == visible name → drift 후보 제외."""
        result = detect_offscreen_drift_proximity_diagnostic(
            visible_ids=["C01"],
            camera_direction="캐릭터A is off-camera in this shot.",
            id_to_name={"C01": "캐릭터A"},
            character_angles=[{"character": "캐릭터A"}],  # in-frame guarantee
        )
        assert result == {}, "in_frame_guarantee: proximity candidate 제외"


class TestEmptyInputs:
    def test_empty_camera_direction(self):
        result = detect_offscreen_drift_proximity_diagnostic(
            visible_ids=["C01"],
            camera_direction="",
            id_to_name={"C01": "캐릭터A"},
        )
        assert result == {}

    def test_no_offscreen_phrase(self):
        result = detect_offscreen_drift_proximity_diagnostic(
            visible_ids=["C01"],
            camera_direction="standard mid-shot",
            id_to_name={"C01": "캐릭터A"},
        )
        assert result == {}
```

- [ ] **Step 2**: Run test to verify FAIL (함수 미정의):

```bash
pytest backend/tests/unit/test_shot_visibility_proximity_diagnostic.py -v
```
Expected: FAIL — `ImportError: cannot import name 'detect_offscreen_drift_proximity_diagnostic'`.

### W3.3: Implement function split in shot_visibility.py

- [ ] **Step 1**: Read current `detect_offscreen_drift` (lines 315-473):

```bash
sed -n '310,475p' backend/app/modules/pipeline/shot_visibility.py
```

- [ ] **Step 2**: Replace `detect_offscreen_drift` 단일 함수 with 2 new functions + delete legacy:

`backend/app/modules/pipeline/shot_visibility.py` 변경 (단계적):

(a) docstring header `:22-24` "auto-fix 안 함" 정확화 (legacy function name 미언급 — W3 residue gate 충돌 회피):
```python
"""shot_director ↔ shot_staging visible/off-camera reconciliation helpers.

2 종류의 deterministic detector:

1. **Producer-side (description-only)** — `detect_gaze_pattern_exclusions`:
   shot_director 가 shot_staging 결과를 보지 못한 채 LLM emit 한
   visible_entity_ids 와 비교하기 위한 lexicon-based diagnostic
   candidate 추출. Area #3 v1: production decision 권한 없음 (audit
   field 만 emit, `shot_director.py:182-201` mutation 폐기).

2. **Consumer-side hybrid (Path 1, structured + OFFSCREEN_RE gate)** —
   `detect_offscreen_drift_structured`: shot_staging v13 의 character_angles
   [].gaze_direction_kind=="looks_at_character" + gaze_target_id 와
   camera_direction 의 OFFSCREEN_RE coarse gate 를 조합. blocking-eligible
   (`VisibleStagingDriftError` raise).

3. **Consumer-side proximity diagnostic (Path 2, NL fallback)** —
   `detect_offscreen_drift_proximity_diagnostic`: camera_direction NL
   proximity 윈도우 안 character name → drift candidate. Diagnostic only
   (caller-side `logger.warning`). Path 1 (structured) miss 또는
   character_angles 부재 시 보조.

Area #3 v1: 이전 merged drift helper 단일 함수 폐기 + Path 1/2 분리.
Path 2 production blocking 권한 제거. mode= param 금지 (silent coupling
회피).
"""
```

(b) `detect_offscreen_drift` 단일 함수 (lines 315-473) 삭제 + 2 신규 함수:

```python
def detect_offscreen_drift_structured(
    visible_ids: Sequence[str],
    camera_direction: str,
    character_angles: Sequence[Dict[str, Any]],
    id_to_name: Mapping[str, str],
) -> Dict[str, str]:
    """Path 1 — hybrid gate (camera_direction OFFSCREEN_RE + structured gaze_target_id).
    
    Gate 1: camera_direction MUST contain explicit offscreen phrase
            (OFFSCREEN_RE match). Frame-internal mutual gaze 정상 (drift 아님).
    Gate 2: character_angles[].gaze_direction_kind == "looks_at_character"
            with valid gaze_target_id resolved against visible_set
            (Q7 dispatch helper, structural validation only — name matching X).
    
    In-frame guarantee: character_angles[].character == visible canonical_name 인
    sid 는 drift 후보에서 영구 제외.
    
    Both gates required. Returns blocking-eligible drift entities.
    """
    if not camera_direction:
        return {}
    off_matches = list(OFFSCREEN_RE.finditer(camera_direction))
    if not off_matches:
        return {}
    
    drift: Dict[str, str] = {}
    visible_set = set(visible_ids)
    
    # canonical_name → visible sid 역방향 — in-frame guarantee용.
    name_to_visible_sid: Dict[str, str] = {}
    for sid in visible_ids:
        nm = (id_to_name.get(sid) or "").strip()
        if not nm or len(nm) < 2:
            continue
        name_to_visible_sid[nm] = sid
    
    # In-frame guarantee
    in_frame_sids: Set[str] = set()
    if character_angles:
        for ca in character_angles:
            if not isinstance(ca, dict):
                continue
            ch = (ca.get("character") or "").strip()
            if not ch or len(ch) < 2:
                continue
            sid = name_to_visible_sid.get(ch)
            if sid:
                in_frame_sids.add(sid)
    
    # Path 1 structured: gaze_direction_kind + gaze_target_id resolution
    if character_angles:
        visible_character_ids = {
            _sid for _sid in visible_set
            if _sid.startswith("C") and _sid[1:].isdigit()
        }
        for ca in character_angles:
            if not isinstance(ca, dict):
                continue
            kind = ca["gaze_direction_kind"]
            if kind != "looks_at_character":
                continue
            target_id = ca.get("gaze_target_id")
            validate_pairing(kind, target_id, where="shot_visibility.path1")
            resolved = resolve_visible_character_target(
                target_id,
                visible_character_ids,
                id_to_name,
                where="shot_visibility.path1",
            )
            if resolved is None:
                continue
            sid, name = resolved
            if (
                sid in visible_set
                and sid not in drift
                and sid not in in_frame_sids
            ):
                drift[sid] = name
    
    return drift


def detect_offscreen_drift_proximity_diagnostic(
    visible_ids: Sequence[str],
    camera_direction: str,
    id_to_name: Mapping[str, str],
    character_angles: Optional[Sequence[Dict[str, Any]]] = None,
) -> Dict[str, str]:
    """Path 2 — camera_direction OFFSCREEN_RE + proximity NL fallback.
    
    Diagnostic only (caller MUST log/warning, MUST NOT raise).
    character_angles (optional) provides in_frame_guarantee against
    false-positive.
    """
    if not camera_direction:
        return {}
    off_matches = list(OFFSCREEN_RE.finditer(camera_direction))
    if not off_matches:
        return {}
    
    drift: Dict[str, str] = {}
    visible_set = set(visible_ids)
    
    # canonical_name → visible sid 역방향
    name_to_visible_sid: Dict[str, str] = {}
    for sid in visible_ids:
        nm = (id_to_name.get(sid) or "").strip()
        if not nm or len(nm) < 2:
            continue
        name_to_visible_sid[nm] = sid
    
    # In-frame guarantee
    in_frame_sids: Set[str] = set()
    if character_angles:
        for ca in character_angles:
            if not isinstance(ca, dict):
                continue
            ch = (ca.get("character") or "").strip()
            if not ch or len(ch) < 2:
                continue
            sid = name_to_visible_sid.get(ch)
            if sid:
                in_frame_sids.add(sid)
    
    # Path 2 proximity NL fallback
    for om in off_matches:
        ws = max(0, om.start() - _PROXIMITY_PRE)
        pre_text = camera_direction[ws:om.start()]
        best_idx = -1
        best_sid = None
        best_name = ""
        for sid in visible_ids:
            if sid in drift or sid in in_frame_sids:
                continue
            name = (id_to_name.get(sid) or "").strip()
            if not name or len(name) < 2:
                continue
            idx = pre_text.rfind(name)
            if idx > best_idx:
                best_idx = idx
                best_sid = sid
                best_name = name
        if best_sid is not None:
            drift[best_sid] = best_name
            continue
        
        we = min(len(camera_direction), om.end() + _PROXIMITY_POST)
        post_text = camera_direction[om.end():we]
        for sid in visible_ids:
            if sid in drift or sid in in_frame_sids:
                continue
            name = (id_to_name.get(sid) or "").strip()
            if not name or len(name) < 2:
                continue
            if name in post_text:
                drift[sid] = name
                break
    return drift
```

(c) `__all__` 갱신:
```python
__all__ = [
    "OFFSCREEN_RE",
    "KOREAN_GAZE_VERB_RE",
    "KOREAN_FRAMING_RE",
    "detect_gaze_pattern_exclusions",
    "detect_offscreen_drift_structured",
    "detect_offscreen_drift_proximity_diagnostic",
]
```

(d) 기존 `detect_offscreen_drift` 함수 완전 삭제 (line 315-473 전체).

- [ ] **Step 2**: Verify imports:

```bash
python -c "from app.modules.pipeline.shot_visibility import detect_offscreen_drift_structured, detect_offscreen_drift_proximity_diagnostic; print('OK')"
```
Expected: `OK`.

```bash
python -c "from app.modules.pipeline.shot_visibility import detect_offscreen_drift" 2>&1 | head -5
```
Expected: `ImportError: cannot import name 'detect_offscreen_drift'`.

### W3.4: Run unit tests to verify pass

- [ ] **Step 1**:

```bash
pytest backend/tests/unit/test_shot_visibility_drift_structured.py backend/tests/unit/test_shot_visibility_proximity_diagnostic.py -v
```
Expected: ALL PASS (4 structured cases + 4 proximity cases).

### W3.5: Update scene_context_loader caller

- [ ] **Step 1**: Read current `_assert_no_visible_staging_drift` (line 443-478):

```bash
sed -n '443,478p' backend/app/core/steps/scene_context_loader.py
```

- [ ] **Step 2**: Replace caller pattern:

`backend/app/core/steps/scene_context_loader.py:443-478` (Edit):

```python
    def _assert_no_visible_staging_drift(self, ctx: SceneAnalysisContext) -> None:
        """shot_director.visible vs shot_staging.camera_direction dual SOT drift
        검사 — Path 1 structured = blocking, Path 2 proximity = diagnostic.
        
        Area #3 W3: function split. Path 1 (character_angles structured) 만
        blocking (VisibleStagingDriftError). Path 2 (proximity NL fallback) 은
        logger.warning only (no raise). mode= param 금지 (silent coupling 회피).
        
        scope: shot_director_ve 와 staging_map 양쪽이 모두 있는 shot 만.
        한쪽이라도 없으면 skip (legacy / 부분 cp 호환). character entity 한정.
        """
        from app.core.errors import VisibleStagingDriftError
        from app.modules.pipeline.shot_visibility import (
            detect_offscreen_drift_structured,
            detect_offscreen_drift_proximity_diagnostic,
        )

        if not ctx.shot_director_ve or not ctx.staging_map:
            return
        if not ctx.name_by_short_id:
            return

        for (si, shi), visible_ids in ctx.shot_director_ve.items():
            staging = ctx.staging_map.get(f"{si}_{shi}") or {}
            cam = staging.get("camera_direction") or ""
            if not cam:
                continue
            char_visible = [sid for sid in visible_ids if sid.startswith("C")]
            if not char_visible:
                continue
            character_angles = staging.get("character_angles") or []
            
            # Path 1 — structured + OFFSCREEN_RE hybrid gate → blocking
            drift_structured = detect_offscreen_drift_structured(
                char_visible,
                camera_direction=cam,
                character_angles=character_angles,
                id_to_name=ctx.name_by_short_id,
            )
            if drift_structured:
                raise VisibleStagingDriftError(
                    shot_label=f"S{si}_Shot{shi}",
                    visible=list(visible_ids),
                    camera_direction=cam,
                    drift_entities=drift_structured,
                )
            
            # Path 2 — proximity NL fallback → diagnostic only (no raise)
            drift_proximity = detect_offscreen_drift_proximity_diagnostic(
                char_visible,
                camera_direction=cam,
                id_to_name=ctx.name_by_short_id,
                character_angles=character_angles,
            )
            if drift_proximity:
                logger.warning(
                    "S%d_Shot%d Path 2 proximity NL drift candidate (diagnostic only): %s",
                    si, shi, drift_proximity,
                )
```

### W3.6: Write residue gate test (legacy function call/import site only)

**Worker note**: Production code (`backend/app/**/*.py`) body / docstring / comment 안에 legacy single-function literal 등장 0 의무. W3.3 docstring 예시는 "이전 merged drift helper 단일 함수" 같이 묘사로만 표현. 단 본 residue test는 docstring/comment 오인 회피 위해 **call site + import site** 만 scan (worker 안전망).

- [ ] **Step 1**: Create `backend/tests/test_residue_detect_offscreen_drift_legacy.py`:

```python
"""Area #3 W3 — detect_offscreen_drift legacy single-function residue gate.

Production code 안에서 legacy single function의 import / call site 0 hits 
의무. Path 1/2 split 후 단일 함수 폐기.

Scope: backend/app/**/*.py 안 import statement + function call site only.
Docstring / comment 안 historical name reference 는 false-positive 회피를 
위해 무시 (단 W3.3 production docstring 작성 시 legacy literal 0 권장 — 
worker note).
"""
import re
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parents[2]
BACKEND_APP = REPO_ROOT / "backend" / "app"

# Match legacy single-function name (NOT followed by underscore — excludes 
# _structured / _proximity_diagnostic).
# Two surfaces caught:
#   1. function call site: `detect_offscreen_drift(`
#   2. import statement: `from ... import ...detect_offscreen_drift` 
#      (where "..." stays single-token, not _structured/_proximity_diagnostic)
LEGACY_CALL_PATTERN = re.compile(r"\bdetect_offscreen_drift\(")
LEGACY_IMPORT_PATTERN = re.compile(
    r"^\s*from\s+app\.modules\.pipeline\.shot_visibility\s+import\s+"
    r"[^#\n]*\bdetect_offscreen_drift\b(?!_)",
    re.MULTILINE,
)


def _is_comment_line(line: str) -> bool:
    """Strip-and-check: is this line a pure comment (starts with #)?"""
    return line.lstrip().startswith("#")


def test_no_legacy_call_or_import_in_production():
    """backend/app 안 legacy single-function call site OR import statement 
    0 hits.
    
    Docstring / comment 안 historical name mention 은 무시 (false-positive 
    회피). W3.3 worker note: production docstring 안 legacy literal 0 권장 
    (test가 catch 못해도 plan 의무).
    """
    hits = []
    for py_file in BACKEND_APP.rglob("*.py"):
        try:
            content = py_file.read_text(encoding="utf-8")
        except UnicodeDecodeError:
            continue
        lines = content.split("\n")
        
        # 1) Call site
        for match in LEGACY_CALL_PATTERN.finditer(content):
            line_num = content[:match.start()].count("\n") + 1
            line = lines[line_num - 1] if line_num <= len(lines) else ""
            if _is_comment_line(line):
                continue
            hits.append(
                f"{py_file.relative_to(REPO_ROOT)}:{line_num}: "
                f"call site: {line.strip()}"
            )
        
        # 2) Import statement
        for match in LEGACY_IMPORT_PATTERN.finditer(content):
            line_num = content[:match.start()].count("\n") + 1
            line = lines[line_num - 1] if line_num <= len(lines) else ""
            hits.append(
                f"{py_file.relative_to(REPO_ROOT)}:{line_num}: "
                f"import: {line.strip()}"
            )
    
    assert len(hits) == 0, (
        f"Found {len(hits)} legacy detect_offscreen_drift call/import site(s) "
        f"in production code: {hits}. Area #3 W3 requires Path 1/2 split — "
        f"use detect_offscreen_drift_structured / "
        f"detect_offscreen_drift_proximity_diagnostic only."
    )
```

- [ ] **Step 2**: Run test:

```bash
pytest backend/tests/test_residue_detect_offscreen_drift_legacy.py -v
```
Expected: PASS.

### W3.7: Commit W3

- [ ] **Step 1**: Stage + commit:

```bash
git add backend/app/modules/pipeline/shot_visibility.py
git add backend/app/core/steps/scene_context_loader.py
git add backend/tests/unit/test_shot_visibility_drift_structured.py
git add backend/tests/unit/test_shot_visibility_proximity_diagnostic.py
git add backend/tests/test_residue_detect_offscreen_drift_legacy.py
git commit -m "$(cat <<'EOF'
refactor(area-3-visibility-physical-presence-sot-v1): W3 — Path 1/2 함수 split + Path 2 diagnostic 격하

shot_visibility.py 변경:
- detect_offscreen_drift 단일 함수 삭제 (deprecated wrapper 금지)
- detect_offscreen_drift_structured 신설: Path 1 hybrid gate 
  (camera_direction OFFSCREEN_RE + character_angles[].gaze_target_id 
  structured resolution), blocking-eligible
- detect_offscreen_drift_proximity_diagnostic 신설: Path 2 proximity 
  NL fallback, diagnostic only (caller logger.warning)
- docstring :22-24 자기 모순 해소 (Path 2 diagnostic only 정확화)
- __all__ 갱신

scene_context_loader.py:443-478 caller 분기:
- Path 1 (drift_structured) → raise VisibleStagingDriftError
- Path 2 (drift_proximity) → logger.warning (no raise)
- mode= param 금지 (silent coupling 회피)

structured 함수는 camera_direction 인자 받음 (OFFSCREEN_RE gate 공통) — 
Q5 정정 흡수. frame-internal mutual gaze 정상 (drift 아님). 
structured path 는 name matching 안 함 (gaze_target_id resolve only).

Area #3 spec §2.5 / §3.2 / §4.4 / §5.1 (W3) verbatim. Codex iter 1 
W3 OFFSCREEN_RE coarse gate wording 흡수.

Tests:
- backend/tests/unit/test_shot_visibility_drift_structured.py: 
  Case A (frame-internal mutual gaze NO drift, CRITICAL) + 
  Case B (hybrid gate blocking) + 
  Case C (structured path does NOT match names) + 
  Case D (in_frame_guarantee) + empty inputs
- backend/tests/unit/test_shot_visibility_proximity_diagnostic.py: 
  proximity NL fallback + in_frame_guarantee + empty inputs
- backend/tests/test_residue_detect_offscreen_drift_legacy.py: 
  legacy single function name production import 0 hits

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git status
```
Expected: working tree clean.

---

## W4: Canary + residue + closure

### W4.1: Write canary focused integration test

- [ ] **Step 1**: Create `backend/tests/test_canary_area_3_focused_integration.py`:

```python
"""Area #3 W4 — canary focused integration.

S12_Shot4 / S12_Shot13 / S19 fixture 기반 shot_director → cp 
→ scene_context_loader caller 통합. W0 baseline 대비 W2/W3 후 
결과 검증.

raw 비교 가능 case (instrumented replay) vs cp-only case 분리.
"""
import json
from pathlib import Path

import pytest


REPO_ROOT = Path(__file__).resolve().parents[2]
W0_JSONL = REPO_ROOT / "backend" / "tests" / "_audit_outputs" / "area_3_w0" / "mutation_inventory.jsonl"


def _load_w0_baseline() -> list[dict]:
    """Load W0 mutation_inventory.jsonl if exists (conditional artifact).
    
    If W0 artifact is committed, verify shape + compare baseline.
    If W0 artifact remains run-local, skip baseline comparison.
    """
    if not W0_JSONL.exists():
        pytest.skip(
            "W0 mutation_inventory.jsonl not present — run-local W0 artifact. "
            "Run W0.1-W0.3 manually for baseline comparison."
        )
    return [json.loads(ln) for ln in W0_JSONL.read_text().strip().split("\n") if ln]


def test_canary_S12_Shot4_post_W2_W3_raw_replay_available():
    """S12_Shot4 close-up gaze pattern: post-W2 mutation 0, raw replay 
    baseline 일치 (successful replay case only).
    """
    baseline = _load_w0_baseline()
    s12_s4 = [r for r in baseline if r["scene_index"] == 12 and r["shot_index"] == 4]
    if not s12_s4:
        pytest.skip("S12_Shot4 not in W0 baseline")
    
    row = s12_s4[0]
    if row.get("raw_source") != "instrumented_replay" or row.get("replay_status") != "success":
        pytest.skip(
            "S12_Shot4 raw replay unavailable — cp-only baseline. "
            "Manual canary verify required (W4 closure checklist)."
        )
    
    # Successful replay: raw LLM emit must be present (non-null array)
    raw = row["raw_llm_visible_entity_ids"]
    assert raw is not None and isinstance(raw, list), (
        f"S12_Shot4 raw_llm_visible_entity_ids must be array on replay success, got {raw!r}"
    )
    # post-W2 production: visible_entity_ids unchanged from raw LLM emit
    # (mutation 권한 박탈 → raw == post_mutation 일치 의무)
    assert sorted(raw) == sorted(row["post_mutation_visible"]), (
        f"S12_Shot4 post-W2 mutation 의심: raw {raw} vs post-mutation "
        f"{row['post_mutation_visible']}. Area #3 W2 mutation 0 violation."
    )


def test_path_1_structured_blocking_still_raises():
    """Path 1 structured drift (OFFSCREEN_RE + character_angles match) → 
    VisibleStagingDriftError raise (W3 후 blocking 유지). 
    No W0 baseline dependency.
    """
    from app.modules.pipeline.shot_visibility import detect_offscreen_drift_structured
    
    drift = detect_offscreen_drift_structured(
        visible_ids=["C01", "C02"],
        camera_direction="캐릭터A is in frame while 캐릭터B remains off-screen.",
        character_angles=[
            {
                "character": "캐릭터A",
                "gaze_direction_kind": "looks_at_character",
                "gaze_target_id": "C02",
            },
        ],
        id_to_name={"C01": "캐릭터A", "C02": "캐릭터B"},
    )
    assert "C02" in drift, "Path 1 structured drift still detected post-W3"
    assert drift["C02"] == "캐릭터B"


def test_path_2_proximity_diagnostic_does_not_raise():
    """Path 2 proximity NL fallback → returns dict (no raise). 
    Diagnostic only post-W3.
    """
    from app.modules.pipeline.shot_visibility import (
        detect_offscreen_drift_proximity_diagnostic,
    )
    
    # No raise expected even when drift candidates exist
    result = detect_offscreen_drift_proximity_diagnostic(
        visible_ids=["C01"],
        camera_direction="캐릭터A is off-camera in this shot.",
        id_to_name={"C01": "캐릭터A"},
    )
    # Function returns dict (no exception). Caller is responsible for log.
    assert isinstance(result, dict)


# S12_Shot13 (body-part possession) / S19 (directional) canary verify:
#   → manual closure checklist (W4 manual review).
#   Reason: cp-only baseline 한계 (instrumented replay 불가 시 raw 비교 
#   skip). assertion 의무가 baseline 가용성에 따라 변함 — automated test 
#   scope 안 들이지 X. W4.2 manual checklist 항목으로 처리.
```

- [ ] **Step 2**: Run test:

```bash
pytest backend/tests/test_canary_area_3_focused_integration.py -v
```
Expected: PASS (W0 artifact 미존재 시 skip, Path 1 structured 정상 동작).

### W4.2: Combined residue gates verify

- [ ] **Step 1**: Run all W1/W2/W3 residue gates:

```bash
pytest \
  backend/tests/test_active_prompt_residue_shot_director_v6.py \
  backend/tests/test_residue_shot_director_mutation_pattern.py \
  backend/tests/test_residue_detect_offscreen_drift_legacy.py \
  -v
```
Expected: ALL PASS.

### W4.3: Update roadmap §5.3 + §11

- [ ] **Step 1**: Read current roadmap §5.3 + §11:

```bash
grep -n "Area #3\|### 5.3\|### 11" docs/superpowers/specs/2026-05-16-track-b-semantic-debt-roadmap-design.md | head -20
```

- [ ] **Step 2**: Update §5.3 status (`pending` → `closed (2026-05-XX, v1 closure)`)와 §11 closed area 표에 Area #3 추가:

`docs/superpowers/specs/2026-05-16-track-b-semantic-debt-roadmap-design.md` 변경:
- §5.3 Area #3 status row → `closed (2026-05-XX)`
- §11 closed area 표에 Area #3 entry 추가:
  ```
  | Area #3 (Visibility / Physical Presence SOT v1, G3 본체) | **closed (2026-05-XX, W4 본 commit)** | W0-W4 atomic. mutation 권한 박탈 (shot_director.py:182-201) + prompt v6 (Korean grammar bundle 제거) + Path 1/2 함수 split. schema 신설 = deferred by policy (post-closure trigger 시 separate follow-up). 4 closure criteria PASS. |
  ```

### W4.4: shot_visibility.py docstring 자기 모순 해소 verify

- [ ] **Step 1**: Read docstring :22-24:

```bash
sed -n '1,30p' backend/app/modules/pipeline/shot_visibility.py
```
Expected: W3.3에서 이미 갱신됨 (Area #3 v1 wording).

- [ ] **Step 2**: Verify no "auto-fix 안 함" 잔존:

```bash
grep -n "auto-fix 안 함" backend/app/modules/pipeline/shot_visibility.py
```
Expected: 0 hits.

### W4.5: Closure memo + roadmap update commit

- [ ] **Step 1**: Stage + commit:

```bash
git add backend/tests/test_canary_area_3_focused_integration.py
git add docs/superpowers/specs/2026-05-16-track-b-semantic-debt-roadmap-design.md
git commit -m "$(cat <<'EOF'
docs(area-3-visibility-physical-presence-sot-v1): W4 — closure + canary + residue + roadmap §5.3/§11 update

Area #3 v1 closure. 4 closure criteria PASS:
- W1 residue gate (7 banned literal 0 hits in active prompt)
- W2 mutation pattern (ls["visible_entity_ids"] = 0 hits)
- W3 legacy function name (detect_offscreen_drift production import 0)
- W4 canary focused integration (S12_Shot4 / S12_Shot13 / S19 + 
  Path 1 structured blocking 정상 동작)

Closure wording (§6 MAY claims only):
- shot_director visibility SOT cleanup complete
- Area #3 prompt residue gates clean for shot_director active prompt

MUST NOT claims (§6 위반 0 verify):
- broad prompt hygiene complete (3 sites carry to Area #7)
- all active prompts clean
- multilingual support verified

Roadmap §5.3 + §11 update: Area #3 closed area 등록.

Post-closure follow-up trigger (§7) monitoring window 시작 (본 
commit push timestamp). Track B Tier 1 #3 (last Tier 1 area) closure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git status
```
Expected: working tree clean.

### W4.6: Codex review iter (사용자 명시 의무)

- [ ] **Step 1**: Codex review iter dispatch (W1-W4 range review):

사용자 명시 의무. `codex:rescue` subagent 사용 X (review는 별도 의무). Codex CLI 직접 사용:
```bash
# 사용자가 별도로 codex review 진행 — Area #1/#2 pattern donor
```

W1-W4 range review pattern (Area #1/#2 closure 시 사용):
- 13 commits range (W0 markdown commit + W1 prompt + W2 mutation + W3 split + W4 closure + canary fixture commits)
- review prompt: range review `<base>..<head>` (Area #2 W9 range commit pattern donor)
- expected verdict: APPROVED_FOR_PUSH (Critical 0, Important carry only)

- [ ] **Step 2**: Codex review result 흡수:
- APPROVED_FOR_PUSH: W4 closure 완료 → push 의무
- NEEDS_REVISION: fix-up commit + 재review iter

### W4.7: Push gate (사용자 명시 승인 후)

- [ ] **Step 1**: 사용자 명시 push 승인 confirm:

> Codex APPROVED_FOR_PUSH 후 사용자에게 명시 push 승인 request. 
> 사용자 명시 승인 전 push 절대 금지 ([[feedback_subagent_model_opus]] + 
> CLAUDE.md "NEVER push to remote repository unless the user explicitly asks").

- [ ] **Step 2**: Push (사용자 승인 후만):

```bash
git push origin main
```
Expected: `<base>..<head>` commits pushed. origin/main 갱신.

### W4.8: closure 종합 memo + 진입점 supersede

- [ ] **Step 1**: Create closure 종합 memo:

`/Users/manta/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/session_20260517_area_3_visibility_physical_presence_sot_v1_closure.md`:

```markdown
---
name: session-20260517-area-3-visibility-physical-presence-sot-v1-closure
description: 2026-05-17 Area #3 Visibility / Physical Presence SOT v1 closure 종합 (W0-W4 atomic + push 완료) — shot_director.py mutation 권한 박탈 + prompt v6 (Korean grammar bundle 제거) + Path 1/2 함수 split + canary verify. schema 신설 deferred by policy. broad prompt hygiene 3 sites Area #7 carry.
metadata:
  type: project
  node_type: memory
---

# Area #3 closure 종합 (Track B Tier 1 #3, 마지막 Tier 1 area)

(closure 디테일 — Area #1/#2 pattern donor)
```

- [ ] **Step 2**: Update `MEMORY.md` index entry + supersede `next_session_area_3_*` 진입점:

```bash
# MEMORY.md 안 entry 추가 (1 line)
# next_session_area_3_visibility_physical_presence_sot.md 안 "해소됨" marker 추가
```

- [ ] **Step 3**: Memory file write only — **git op 금지**:

memory directory `/Users/manta/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/` 는 본 repo (`/Users/manta/Documents/Projects/TheRoad-I1`) scope 외부. Write tool로 file 작성만 (closure 종합 memo + MEMORY.md index entry + supersede `next_session_area_3_*` 진입점).

```
Files to write (external memory, NO git op):
  - memory/session_20260517_area_3_visibility_physical_presence_sot_v1_closure.md (new)
  - memory/MEMORY.md (append 1 line entry)
  - memory/next_session_area_3_visibility_physical_presence_sot.md (append 해소 marker)
```

**금지**: `git add /Users/manta/.claude/projects/...` 명령 절대 사용 X (repo 외부 path, broken op).

---

## Self-Review

### Spec coverage
- §1 Motivation → W0-W4 wave 안 변경 대상 + 산출물 모두 cover
- §2 Decision Summary (Q1-Q7) → 각 wave 안 verbatim 반영
- §3 Architecture → File Structure + W2/W3 sub-task 분해
- §4 Wave Outline → W0/W1/W2/W3/W4 1:1 task
- §5 Test Cascade → 각 wave 안 TDD step + W4 combined verify
- §6 Closure Conditions → W4.5 commit message + W4.6/W4.7 push gate
- §7 Post-closure Follow-up Trigger → spec verbatim (plan에서 monitoring window 시작 commit timestamp 명시)
- §8 Broad Prompt Hygiene Carry → 본 plan scope 외 (Area #7 carry note in spec)
- §9 Multilingual Framing → W1 prompt v6 system.md header verbatim
- §11 Closure Verify Checklist → W4.6/W4.7 단계

### Placeholder scan
- `YYYYMMDDHHMM` (prompt timestamp) — W1.2 Step 1에서 결정. acceptable.
- `2026-05-XX` (closure date) — W4.3/W4.5에서 commit 시점 결정. acceptable.
- `<base>..<head>` (push range) — W4.6/W4.7에서 결정. acceptable.
- TBD / TODO 잔존: 0

### Type consistency
- `detect_offscreen_drift_structured` signature: `visible_ids, camera_direction, character_angles, id_to_name` — W3.1 / W3.3 / W3.5 모두 일치.
- `detect_offscreen_drift_proximity_diagnostic` signature: `visible_ids, camera_direction, id_to_name, character_angles=None` — W3.2 / W3.3 / W3.5 모두 일치.
- `excluded_offscreen_entity_ids` field name: W2.1 / W2.2 / W4.5 모두 일치.
- `SHOT_DIRECTOR_PROMPT_VERSION` 변경: W1.3 / W1.5 모두 일치.

### Wave dependency
- W0 → W1/W2/W3 진입 gate (사용자 명시 review 의무, W0.4)
- W1 / W2 / W3 parallel 가능 (file overlap 없음)
- W4 = W1/W2/W3 모두 closure 후

---

## Execution Handoff

**Plan complete and saved to `docs/superpowers/plans/2026-05-17-area-3-visibility-physical-presence-sot-v1.md`. Two execution options:**

**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task (per wave or sub-task), review between tasks, fast iteration. Each subagent gets the spec + plan + W reference. Two-stage review (implementation reviewer + Codex iter).

**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints for review.

**Which approach?**
