# Area #2 — State / Gaze Separation 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:** Separate `gaze_target` field 의 3차원 overload (시선 방향 + 동적 target identity + character state) 를 3 structured field (`gaze_direction_kind` enum + `gaze_target_id` registered short_id + `subject_state` enum) 로 분리. 7+ consumer literal compare → helper-based enum check 로 migration. Patch B-min (semantic_contract_router) IMMOBILIZED_GAZE 즉시 폐기 + Rule 1 source 전환. shot_visibility Path 1 흡수 (Path 2 Area #3 boundary 유지).

**Architecture:** 4-layer (Producer → Helper SOT → Consumer → Prompt). `shot_staging v13` 가 3 field 신설 + `gaze_target` 완전 제거; helper 2 module (`subject_state.py` + `gaze_direction.py`) 차원 독립 SOT; semantic_contract_router Rule 1 = `subject_state in IMMOBILIZED_STATES` (helper import); 8 consumer site enum-based migration; shot_staging v13 system.md gaze section 전면 rewrite (Gate 2 정합).

**Tech Stack:** Python 3 (FastAPI backend), pytest, JSON schema validation (oneOf conditional, kind 별 pattern branch), prompt_loader (stem-independent latest pick), step_manifest / version_registry dual sync.

**Spec reference:** `docs/superpowers/specs/2026-05-17-area-2-state-gaze-separation-design.md` (commit `30b62f8` v1 + `9505b08` Q7 fix-up + `c2ee0c7` Q7 wording + `30ab30f` Q7 fix-up iter 2).

---

## Wave Ordering + Dependencies

```
W1 (Helper modules) ─┬─→ W3 (router)
                     ├─→ W4 (image_steps)
                     ├─→ W5 (scene ref + asset readiness + detail mirror)
                     ├─→ W6 (prompt injection)
                     └─→ W7 (shot_visibility Path 1)
W2 (shot_staging v13) ─→ (W3, W4, W5, W6, W7 schema 정합 가정)
W3, W4, W5, W6, W7 ─→ W8 (test cascade full sweep)
W8 ─→ W9 (closure + roadmap amend + push gate)
```

각 wave = atomic commit (wave 안 모든 변경 한 commit). W8 = sub-wave 분할 가능 (cascade pattern 단위 — Area #1 W7 lesson 정합). W9 = closure + roadmap amend + Patch B-min memo wording 정정.

---

## W1: Helper modules + unit tests

Foundation wave — 다른 모든 wave 가 helper API 의존.

**Files:**
- Create: `backend/app/core/subject_state.py`
- Create: `backend/app/core/gaze_direction.py`
- Create: `backend/tests/unit/test_subject_state_helper.py`
- Create: `backend/tests/unit/test_gaze_direction_helper.py`

### W1.1: Write failing tests — subject_state helper

- [ ] Create `backend/tests/unit/test_subject_state_helper.py`:

```python
"""Area #2 — subject_state helper unit tests (Q2 + Q6 closure)."""
import pytest

from app.core.errors import AppError
from app.core.subject_state import (
    SUBJECT_STATES,
    IMMOBILIZED_STATES,
    SUBJECT_STATE_VISUAL_DESCRIPTOR,
    is_immobilized_state,
    validate_subject_state,
    get_visual_descriptor,
)


class TestSubjectStateConstants:
    def test_subject_states_tuple(self):
        assert SUBJECT_STATES == ("alive", "unconscious", "dead", "severely_injured")

    def test_immobilized_states_frozenset(self):
        assert IMMOBILIZED_STATES == frozenset({"unconscious", "dead", "severely_injured"})
        assert "alive" not in IMMOBILIZED_STATES

    def test_visual_descriptor_keys(self):
        assert set(SUBJECT_STATE_VISUAL_DESCRIPTOR.keys()) == {"dead", "severely_injured", "unconscious"}
        assert "alive" not in SUBJECT_STATE_VISUAL_DESCRIPTOR

    def test_visual_descriptor_non_empty(self):
        for state, desc in SUBJECT_STATE_VISUAL_DESCRIPTOR.items():
            assert isinstance(desc, str) and len(desc) > 10


class TestIsImmobilizedState:
    @pytest.mark.parametrize("state,expected", [
        ("dead", True),
        ("unconscious", True),
        ("severely_injured", True),
        ("alive", False),
        ("invalid_state", False),  # silent False — fixture/우회 silent miss 는 validate_subject_state cover
        ("", False),
        (None, False),
    ])
    def test_is_immobilized_state(self, state, expected):
        assert is_immobilized_state(state) is expected


class TestValidateSubjectState:
    @pytest.mark.parametrize("state", SUBJECT_STATES)
    def test_valid_states_pass(self, state):
        validate_subject_state(state, where="test")  # no raise

    @pytest.mark.parametrize("state", ["invalid", "", "ALIVE", "Dead"])
    def test_invalid_states_fail(self, state):
        with pytest.raises(AppError) as exc_info:
            validate_subject_state(state, where="test.subject_state")
        assert "step.contract_violation.subject_state.enum" in str(exc_info.value)


class TestGetVisualDescriptor:
    @pytest.mark.parametrize("state", ["dead", "severely_injured", "unconscious"])
    def test_immobilized_states_return_descriptor(self, state):
        desc = get_visual_descriptor(state)
        assert isinstance(desc, str) and len(desc) > 10

    def test_alive_raises(self):
        with pytest.raises(AppError) as exc_info:
            get_visual_descriptor("alive")
        assert "subject_state.visual_descriptor_not_defined" in str(exc_info.value)

    def test_invalid_raises(self):
        with pytest.raises(AppError) as exc_info:
            get_visual_descriptor("invalid")
        assert "subject_state.visual_descriptor_not_defined" in str(exc_info.value)
```

### W1.2: Write failing tests — gaze_direction helper

- [ ] Create `backend/tests/unit/test_gaze_direction_helper.py`:

```python
"""Area #2 — gaze_direction helper unit tests (Q1 + Q3 + Q7 closure)."""
import pytest

from app.core.errors import AppError
from app.core.gaze_direction import (
    GAZE_DIRECTION_KINDS,
    TARGET_REQUIRED_KINDS,
    is_target_required,
    validate_pairing,
    resolve_visible_character_target,
)


class TestGazeDirectionConstants:
    def test_kinds_tuple(self):
        assert GAZE_DIRECTION_KINDS == (
            "camera", "down", "up", "distant", "closed_eyes", "off_screen",
            "looks_at_character", "looks_at_object",
        )

    def test_target_required_kinds(self):
        assert TARGET_REQUIRED_KINDS == frozenset({"looks_at_character", "looks_at_object"})


class TestIsTargetRequired:
    @pytest.mark.parametrize("kind,expected", [
        ("looks_at_character", True),
        ("looks_at_object", True),
        ("camera", False),
        ("down", False),
        ("closed_eyes", False),
        ("off_screen", False),
    ])
    def test_is_target_required(self, kind, expected):
        assert is_target_required(kind) is expected


class TestValidatePairing:
    @pytest.mark.parametrize("kind,target_id", [
        ("looks_at_character", "C01"),
        ("looks_at_character", "C123"),
        ("looks_at_object", "P01"),
        ("looks_at_object", "B12"),
        ("camera", None),
        ("distant", None),
        ("off_screen", None),
    ])
    def test_valid_pairing_pass(self, kind, target_id):
        validate_pairing(kind, target_id, where="test")  # no raise

    def test_invalid_kind_fails(self):
        with pytest.raises(AppError) as exc_info:
            validate_pairing("invalid_kind", "C01", where="test")
        assert "gaze_direction_kind.enum" in str(exc_info.value)

    def test_target_required_missing_fails(self):
        with pytest.raises(AppError) as exc_info:
            validate_pairing("looks_at_character", None, where="test")
        assert "gaze_target_id.missing_for_target_kind" in str(exc_info.value)

    def test_non_target_with_id_fails(self):
        with pytest.raises(AppError) as exc_info:
            validate_pairing("camera", "C01", where="test")
        assert "gaze_target_id.present_for_non_target_kind" in str(exc_info.value)

    @pytest.mark.parametrize("target_id", ["phone", "O01", "강민준", "c01", "C1"])
    def test_invalid_shape_for_character_fails(self, target_id):
        with pytest.raises(AppError) as exc_info:
            validate_pairing("looks_at_character", target_id, where="test")
        assert "gaze_target_id.shape" in str(exc_info.value)

    @pytest.mark.parametrize("target_id", ["phone", "C01", "O01", "p01"])
    def test_invalid_shape_for_object_fails(self, target_id):
        with pytest.raises(AppError) as exc_info:
            validate_pairing("looks_at_object", target_id, where="test")
        assert "gaze_target_id.shape" in str(exc_info.value)


class TestResolveVisibleCharacterTarget:
    def test_valid_target_returns_tuple(self):
        result = resolve_visible_character_target(
            "C01",
            visible_character_ids={"C01", "C02"},
            id_to_name={"C01": "Alpha", "C02": "Beta"},
            where="test",
        )
        assert result == ("C01", "Alpha")

    def test_invalid_shape_returns_none(self):
        result = resolve_visible_character_target(
            "phone",
            visible_character_ids={"C01"},
            id_to_name={"C01": "Alpha"},
            where="test",
        )
        assert result is None

    def test_not_in_visible_returns_none(self):
        result = resolve_visible_character_target(
            "C03",
            visible_character_ids={"C01", "C02"},
            id_to_name={"C01": "Alpha", "C02": "Beta"},
            where="test",
        )
        assert result is None

    def test_missing_name_returns_none(self):
        result = resolve_visible_character_target(
            "C01",
            visible_character_ids={"C01"},
            id_to_name={},  # name 누락
            where="test",
        )
        assert result is None
```

### W1.3: Run tests (verify all fail)

- [ ] Run:
  ```bash
  cd backend && pytest tests/unit/test_subject_state_helper.py tests/unit/test_gaze_direction_helper.py -v
  ```
  Expected: All FAIL with `ImportError: cannot import name '...' from 'app.core.subject_state'` (또는 `gaze_direction`).

### W1.4: Implement `subject_state.py`

- [ ] Create `backend/app/core/subject_state.py`:

```python
"""Subject state enum SOT + visual descriptor lookup (Area #2 Q2 + Q6).

closed-world enum only — LLM producer (shot_staging v13) emit subject_state,
consumer = enum check via is_immobilized_state / get_visual_descriptor.
regex / substring / NL fallback 금지 (Gate 1).

is_immobilized_state silent False (consumer side fail-fast = schema validation).
validate_subject_state = defense in depth (fixture / corrupt CP / migration tool).
"""
from __future__ import annotations

from app.core.errors import AppError


SUBJECT_STATES: tuple[str, ...] = ("alive", "unconscious", "dead", "severely_injured")
IMMOBILIZED_STATES: frozenset[str] = frozenset({"unconscious", "dead", "severely_injured"})

SUBJECT_STATE_VISUAL_DESCRIPTOR: dict[str, str] = {
    "dead": "lying motionless, pale/ashen skin, eyes fully closed, slack facial muscles, no signs of life",
    "severely_injured": "visible bruises and cuts, bloodied areas on face or clothing, pained or grimacing expression, disheveled appearance",
    "unconscious": "eyes closed, slack facial features, limp posture, head tilted to one side",
}


def is_immobilized_state(state: str) -> bool:
    return state in IMMOBILIZED_STATES


def validate_subject_state(state: str, where: str) -> None:
    if state not in SUBJECT_STATES:
        raise AppError(
            "step.contract_violation.subject_state.enum",
            f"{where}: invalid subject_state={state!r}, expected one of {SUBJECT_STATES}",
        )


def get_visual_descriptor(state: str) -> str:
    if state not in SUBJECT_STATE_VISUAL_DESCRIPTOR:
        raise AppError(
            "step.contract_violation.subject_state.visual_descriptor_not_defined",
            f"subject_state={state!r} has no visual descriptor (valid: {sorted(SUBJECT_STATE_VISUAL_DESCRIPTOR.keys())})",
        )
    return SUBJECT_STATE_VISUAL_DESCRIPTOR[state]
```

### W1.5: Implement `gaze_direction.py`

- [ ] Create `backend/app/core/gaze_direction.py`:

```python
"""Gaze direction enum SOT + target pairing + dispatch helper (Area #2 Q1 + Q3 + Q7).

closed-world enum only — LLM producer (shot_staging v13) emit gaze_direction_kind +
gaze_target_id, consumer = enum check via helper.
regex / substring / NL fallback 금지 (Gate 1).

validate_pairing = defense in depth (production runtime primary defense = schema oneOf).
Q7 closure: gaze_target_id shape = registered short_id only.
"""
from __future__ import annotations

import re

from app.core.errors import AppError


GAZE_DIRECTION_KINDS: tuple[str, ...] = (
    "camera", "down", "up", "distant", "closed_eyes", "off_screen",
    "looks_at_character", "looks_at_object",
)

TARGET_REQUIRED_KINDS: frozenset[str] = frozenset({"looks_at_character", "looks_at_object"})

_CHAR_ID_RE = re.compile(r"^C\d{2,3}$")
_OBJECT_ID_RE = re.compile(r"^(P|B)\d{2,3}$")


def is_target_required(kind: str) -> bool:
    return kind in TARGET_REQUIRED_KINDS


def validate_pairing(kind: str, target_id: str | None, where: str) -> None:
    if kind not in GAZE_DIRECTION_KINDS:
        raise AppError(
            "step.contract_violation.gaze_direction_kind.enum",
            f"{where}: invalid kind={kind!r}, expected one of {GAZE_DIRECTION_KINDS}",
        )
    target_required = is_target_required(kind)
    if target_required and not target_id:
        raise AppError(
            "step.contract_violation.gaze_target_id.missing_for_target_kind",
            f"{where}: kind={kind!r} requires gaze_target_id",
        )
    if not target_required and target_id:
        raise AppError(
            "step.contract_violation.gaze_target_id.present_for_non_target_kind",
            f"{where}: kind={kind!r} forbids gaze_target_id (got {target_id!r})",
        )
    # Q7 closure: shape validation (closed-world registered short_id only)
    if target_required:
        if kind == "looks_at_character" and not _CHAR_ID_RE.match(target_id or ""):
            raise AppError(
                "step.contract_violation.gaze_target_id.shape",
                f"{where}: kind=looks_at_character requires ^C\\d{{2,3}}$ (got {target_id!r})",
            )
        if kind == "looks_at_object" and not _OBJECT_ID_RE.match(target_id or ""):
            raise AppError(
                "step.contract_violation.gaze_target_id.shape",
                f"{where}: kind=looks_at_object requires ^(P|B)\\d{{2,3}}$ (got {target_id!r})",
            )


def resolve_visible_character_target(
    target_id: str,
    visible_character_ids: set[str],
    id_to_name: dict[str, str],
    where: str,
) -> tuple[str, str] | None:
    """target_id (C##) → (sid, canonical_name) lookup.

    structural validation only: C## shape + visible_set membership.
    None = visible 밖 → consumer 가 결정 (skip or warn).
    name matching / noun matching / character/object 재판정 금지 (Gate 1)."""
    if not _CHAR_ID_RE.match(target_id or ""):
        return None
    if target_id not in visible_character_ids:
        return None
    name = id_to_name.get(target_id)
    if not name:
        return None
    return (target_id, name)
```

### W1.6: Run tests (verify all pass)

- [ ] Run:
  ```bash
  cd backend && pytest tests/unit/test_subject_state_helper.py tests/unit/test_gaze_direction_helper.py -v
  ```
  Expected: All PASS.

### W1.7: Commit W1 (atomic)

- [ ] Stage + commit:
  ```bash
  git add backend/app/core/subject_state.py backend/app/core/gaze_direction.py \
          backend/tests/unit/test_subject_state_helper.py backend/tests/unit/test_gaze_direction_helper.py
  git commit -m "$(cat <<'EOF'
feat(area-2-state-gaze-separation): W1 — helper modules + unit tests

backend/app/core/subject_state.py:
- SUBJECT_STATES tuple (alive/unconscious/dead/severely_injured)
- IMMOBILIZED_STATES frozenset (alive 제외)
- SUBJECT_STATE_VISUAL_DESCRIPTOR dict (3 entries, alive 제외)
- is_immobilized_state (silent False on 비enum)
- validate_subject_state (fail-fast AppError, fixture/우회 silent miss 차단)
- get_visual_descriptor (alive/비enum AppError)

backend/app/core/gaze_direction.py:
- GAZE_DIRECTION_KINDS tuple (8 entries)
- TARGET_REQUIRED_KINDS frozenset
- is_target_required
- validate_pairing (enum + presence + Q7 shape validation: ^C\\d{2,3}$ / ^(P|B)\\d{2,3}$)
- resolve_visible_character_target (Q7 dispatch helper, structural validation only)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  ```

---

## W2: shot_staging v13 producer atomic

shot_staging v13 prompt directory + schema + step_manifest + version_registry + schema/version tests — **한 commit** (prompt_loader stem 독립 탐색 race 차단).

**Files:**
- Create: `prompts/_base/shot_staging/13.<TIMESTAMP>/system.md` (v12 base + gaze section rewrite)
- Create: `prompts/_base/shot_staging/13.<TIMESTAMP>/schema.json` (v12 base + 3 field 신설 + oneOf + gaze_target 제거)
- Modify: `backend/app/core/step_manifest.py:599` schema_version 4 → 5
- Modify: `backend/app/core/version_registry.py:33` shot_staging "2.5.0" → "2.6.0"
- Modify: `backend/app/core/version_registry.py:122` prompt_dependency "shot_staging/v12" → "shot_staging/v13"
- Modify: `backend/tests/test_prompt_versions.py` — v12 hardcoded → v13 current schema

**TIMESTAMP** = 작성 시각 (`date -u +%Y%m%d%H%M`).

### W2.1: Copy v12 pack to v13

- [ ] Determine TIMESTAMP:
  ```bash
  date -u +%Y%m%d%H%M
  ```

- [ ] Copy v12 pack (⚠️ v12 명시 — partial v13 self-copy risk 차단):
  ```bash
  SRC=$(ls -1 prompts/_base/shot_staging/ | grep '^12\.' | sort -V | tail -1)
  TS=<paste from above>
  cp -r prompts/_base/shot_staging/$SRC prompts/_base/shot_staging/13.$TS
  ```

### W2.2: Edit `schema.json` — 3 field 신설 + oneOf + gaze_target 제거

- [ ] Edit `prompts/_base/shot_staging/13.<TS>/schema.json` — `character_angles[].items.properties` 에서 `gaze_target` 제거 + 3 field 신설 + `oneOf` conditional 추가:

```json
{
  "character_angles": {
    "type": "array",
    "description": "Camera-relative angle, body pose, gaze direction, target, and state for each visible character (exclude POV character)",
    "items": {
      "type": "object",
      "properties": {
        "character": {"type": "string", "description": "Character name (as registered)"},
        "angle": {"type": "string", "description": "Camera-relative direction: facing_camera, back_to_camera, profile_left, profile_right, three_quarter_left, three_quarter_right, over_shoulder, looking_away"},
        "body_pose": {"type": "string", "description": "Specific body posture in English (2-5 words). Must NOT use 'standing' alone — use action-grounded or state-grounded poses."},
        "gaze_direction_kind": {
          "type": "string",
          "enum": ["camera", "down", "up", "distant", "closed_eyes", "off_screen", "looks_at_character", "looks_at_object"],
          "description": "Eye direction kind. Closed-world enum — LLM judges intent class only. closed_eyes = eye-closed only (not state). off_screen = eyes outside camera frame, no registered target resolved (NOT visibility decision)."
        },
        "gaze_target_id": {
          "type": ["string", "null"],
          "description": "Optional. Required when gaze_direction_kind ∈ {looks_at_character, looks_at_object}; null/omitted otherwise. Shape (Q7 closed): registered short_id only — ^C\\d{2,3}$ for looks_at_character; ^P\\d{2,3}$ or ^B\\d{2,3}$ for looks_at_object. outlook O## / canonical name / Korean noun / ASCII noun fallback 모두 금지. Non-registered target case = looks_at_* 사용 금지, off_screen/distant 등 사용."
        },
        "subject_state": {
          "type": "string",
          "enum": ["alive", "unconscious", "dead", "severely_injured"],
          "description": "Character state — separate axis from gaze direction. 'alive' explicit; missing = schema violation. immobility (의식 있지만 못 움직임) = 별도 차원 (character_state element)."
        }
      },
      "required": ["character", "angle", "body_pose", "gaze_direction_kind", "subject_state"],
      "additionalProperties": false,
      "oneOf": [
        {
          "properties": {
            "gaze_direction_kind": {"enum": ["looks_at_character"]},
            "gaze_target_id": {"type": "string", "pattern": "^C\\d{2,3}$"}
          },
          "required": ["gaze_target_id"]
        },
        {
          "properties": {
            "gaze_direction_kind": {"enum": ["looks_at_object"]},
            "gaze_target_id": {"type": "string", "pattern": "^(P|B)\\d{2,3}$"}
          },
          "required": ["gaze_target_id"]
        },
        {
          "properties": {
            "gaze_direction_kind": {"enum": ["camera", "down", "up", "distant", "closed_eyes", "off_screen"]},
            "gaze_target_id": {"type": "null"}
          }
        }
      ]
    }
  }
}
```

⚠️ 주의: `gaze_target` field 완전 제거 (deprecated_* / legacy_* archival 금지).

### W2.3: Edit `system.md` — gaze section 전면 rewrite

- [ ] Edit `prompts/_base/shot_staging/13.<TS>/system.md` line 115-124 (기존 "시선 방향 (gaze_target) — 매우 중요" section) — 전면 삭제 후 신규 prose:

```markdown
**시선 방향 (gaze_direction_kind) + 시선 대상 (gaze_target_id) + 인물 상태 (subject_state) — 매우 중요:**

이 3 field 는 서로 다른 차원입니다. 한 field 에 다른 차원 정보 섞지 마세요.

**gaze_direction_kind** (8 entries, 항상 emit 의무):
- `camera` — 카메라를 직접 봄
- `down` — 아래를 봄 (별 target 없이)
- `up` — 위를 봄
- `distant` — 먼 곳 (no specific target)
- `closed_eyes` — 눈을 감고 있음 (state 아님 — 의식 있고 눈만 감음)
- `off_screen` — 시선이 camera frame 밖 (specific registered target 없음)
- `looks_at_character` — 등록된 다른 인물 봄 (gaze_target_id 필수 — C## form)
- `looks_at_object` — 등록된 사물/배경 봄 (gaze_target_id 필수 — P## or B## form)

**gaze_target_id** (conditional):
- `looks_at_character` 시 = registered character short_id (`^C\d{2,3}$` 형식)
- `looks_at_object` 시 = registered prop/background short_id (`^P\d{2,3}$` 또는 `^B\d{2,3}$`)
- 그 외 = omit 또는 null
- **금지**: registered short_id (C##/P##/B## 형식) 이 아닌 모든 이름/명사/free text. (outlook ID O## 포함, canonical name 포함, 한국어/영어 noun fallback 포함 — Gate 2 정합 위해 active prompt 안 noun 예시 0)
- non-registered target 보는 경우 = `looks_at_*` 사용 금지, 대신 `off_screen` / `distant` 등 적절한 non-target kind 사용

**subject_state** (4 entries, 항상 emit 의무):
- `alive` — 살아 있고 의식 있음 (default — 명시 의무, missing = schema violation)
- `unconscious` — 의식 잃음
- `dead` — 사망
- `severely_injured` — 심각한 부상 (의식 가능 / 불가능 무관)

**금지**: subject_state 안 immobility (의식 있지만 못 움직임 — 결박/마비 등) 차원 합치지 말 것. 그건 character_state element (별도 차원, 본 schema 안 안 다룸).

**예시** (synthetic):
```json
{"character": "<등록 인물 A>", "angle": "facing_camera", "body_pose": "leaning on doorframe",
 "gaze_direction_kind": "looks_at_character", "gaze_target_id": "C03", "subject_state": "alive"}

{"character": "<등록 인물 B>", "angle": "profile_left", "body_pose": "slumped against wall",
 "gaze_direction_kind": "closed_eyes", "subject_state": "unconscious"}

{"character": "<등록 인물 C>", "angle": "back_to_camera", "body_pose": "kneeling on floor",
 "gaze_direction_kind": "down", "subject_state": "alive"}
```
```

⚠️ Gate 2 정합: 작품 고유명사 / 시나리오 특정 noun 0. registered character 는 placeholder 형식 (`<등록 인물 A>`) 만.

### W2.4: Write failing schema/version tests

- [ ] Create `backend/tests/test_shot_staging_v13_schema.py`:

```python
"""Area #2 — shot_staging v13 schema/version sync tests (W2 closure)."""
import json
from pathlib import Path
import pytest

from app.modules.prompt_loader import load_prompt, load_schema
from app.core.step_manifest import STEP_MANIFEST
from app.core.version_registry import MODULE_VERSIONS, MODULE_DEPENDENCIES


REPO_ROOT = Path(__file__).resolve().parent.parent.parent  # backend/tests/test_*.py → repo root
SHOT_STAGING_DIR = REPO_ROOT / "prompts" / "_base" / "shot_staging"


class TestShotStagingV13DirectoryExists:
    def test_v13_directory_present(self):
        v13_dirs = list(SHOT_STAGING_DIR.glob("13.*"))
        assert len(v13_dirs) >= 1

    def test_v13_system_md_loaded(self):
        # loader signature: load_prompt(module, name, ...) -> str
        # v13 latest pick 검증: 신 field 명 포함 + legacy gaze_target section 미포함
        system_text = load_prompt("shot_staging", "system")
        assert isinstance(system_text, str)
        assert "gaze_direction_kind" in system_text, "v13 신 field gaze_direction_kind 부재"
        assert "subject_state" in system_text, "v13 신 field subject_state 부재"
        # legacy: v12 gaze_target prose section 폐기 verify
        assert "시선 방향 (gaze_target)" not in system_text, "v12 legacy gaze_target prose 잔존"

    def test_v13_schema_loaded(self):
        schema = load_schema("shot_staging", "schema")
        assert isinstance(schema, dict) and "properties" in schema
        # v13 latest pick 검증: character_angles.items.properties 안 3 field 있고 gaze_target 없음
        char_angles_props = schema["properties"]["shots"]["items"]["properties"]["character_angles"]["items"]["properties"]
        assert "gaze_direction_kind" in char_angles_props
        assert "gaze_target_id" in char_angles_props
        assert "subject_state" in char_angles_props
        assert "gaze_target" not in char_angles_props, "v12 legacy gaze_target field 잔존"


class TestShotStagingV13Schema:
    @pytest.fixture
    def schema(self):
        v13_dirs = sorted(SHOT_STAGING_DIR.glob("13.*"))
        return json.loads((v13_dirs[-1] / "schema.json").read_text())

    def test_gaze_target_removed(self, schema):
        char_angles_props = schema["properties"]["shots"]["items"]["properties"]["character_angles"]["items"]["properties"]
        assert "gaze_target" not in char_angles_props

    def test_three_new_fields_present(self, schema):
        char_angles_props = schema["properties"]["shots"]["items"]["properties"]["character_angles"]["items"]["properties"]
        assert "gaze_direction_kind" in char_angles_props
        assert "gaze_target_id" in char_angles_props
        assert "subject_state" in char_angles_props

    def test_gaze_direction_kind_enum_8_entries(self, schema):
        kind = schema["properties"]["shots"]["items"]["properties"]["character_angles"]["items"]["properties"]["gaze_direction_kind"]
        assert set(kind["enum"]) == {"camera", "down", "up", "distant", "closed_eyes", "off_screen", "looks_at_character", "looks_at_object"}

    def test_subject_state_enum_4_entries(self, schema):
        state = schema["properties"]["shots"]["items"]["properties"]["character_angles"]["items"]["properties"]["subject_state"]
        assert set(state["enum"]) == {"alive", "unconscious", "dead", "severely_injured"}

    def test_required_includes_kind_and_state(self, schema):
        required = schema["properties"]["shots"]["items"]["properties"]["character_angles"]["items"]["required"]
        assert "gaze_direction_kind" in required
        assert "subject_state" in required
        assert "gaze_target_id" not in required  # oneOf conditional

    def test_oneof_has_three_branches(self, schema):
        oneof = schema["properties"]["shots"]["items"]["properties"]["character_angles"]["items"]["oneOf"]
        assert len(oneof) == 3
        # branch 1: looks_at_character + ^C\d{2,3}$
        branch_char = oneof[0]
        assert branch_char["properties"]["gaze_direction_kind"]["enum"] == ["looks_at_character"]
        assert branch_char["properties"]["gaze_target_id"]["pattern"] == "^C\\d{2,3}$"
        # branch 2: looks_at_object + ^(P|B)\d{2,3}$
        branch_obj = oneof[1]
        assert branch_obj["properties"]["gaze_direction_kind"]["enum"] == ["looks_at_object"]
        assert branch_obj["properties"]["gaze_target_id"]["pattern"] == "^(P|B)\\d{2,3}$"
        # branch 3: non-target + null
        branch_none = oneof[2]
        assert set(branch_none["properties"]["gaze_direction_kind"]["enum"]) == {"camera", "down", "up", "distant", "closed_eyes", "off_screen"}
        assert branch_none["properties"]["gaze_target_id"]["type"] == "null"


class TestShotStagingVersionSync:
    def test_step_manifest_schema_version_5(self):
        assert STEP_MANIFEST["shot_staging"]["schema_version"] == 5

    def test_version_registry_2_6_0(self):
        assert MODULE_VERSIONS["shot_staging"] == "2.6.0"

    def test_prompt_dependency_v13(self):
        assert MODULE_DEPENDENCIES["shot_staging"]["prompt_dependency"] == "shot_staging/v13"
```

### W2.5: Run tests (verify fail)

- [ ] Run:
  ```bash
  cd backend && pytest tests/test_shot_staging_v13_schema.py -v
  ```
  Expected: All FAIL (schema_version still 4 / 2.5.0 / v12 — sync 아직 안 함).

### W2.6: Bump `step_manifest.py` schema_version 4 → 5

- [ ] Edit `backend/app/core/step_manifest.py:599`:

```python
"shot_staging": {
    ...
    "schema_version": 5,  # 2026-05-17 (Area #2): 3 field 분리 (gaze_direction_kind + gaze_target_id + subject_state), gaze_target 폐기. 이전: 4 (subject_reference_policy, Area #1).
    ...
}
```

### W2.7: Bump `version_registry.py` shot_staging + prompt_dependency

- [ ] Edit `backend/app/core/version_registry.py:33`:

```python
"shot_staging": "2.6.0",              # 2026-05-17 — v13 prompt + schema_version 5: 3 field 분리 (gaze_direction_kind/gaze_target_id/subject_state), gaze_target 폐기 (Area #2).
```

- [ ] Edit `backend/app/core/version_registry.py:122`:

```python
"shot_staging": {
    "prompt_dependency": "shot_staging/v13",
    ...
}
```

### W2.8: Update `test_prompt_versions.py`

- [ ] Read current test file:
  ```bash
  grep -n "shot_staging\|v12\|2.5.0\|schema_version.*4" backend/tests/test_prompt_versions.py | head -10
  ```

- [ ] Edit relevant test cases — replace v12/2.5.0/4 → v13/2.6.0/5 hardcoded values.

### W2.9: Run tests (verify all pass)

- [ ] Run:
  ```bash
  cd backend && pytest tests/test_shot_staging_v13_schema.py tests/test_prompt_versions.py -v
  ```
  Expected: All PASS.

### W2.10: Commit W2 (atomic)

- [ ] Stage + commit:
  ```bash
  git add prompts/_base/shot_staging/13.* \
          backend/app/core/step_manifest.py backend/app/core/version_registry.py \
          backend/tests/test_shot_staging_v13_schema.py backend/tests/test_prompt_versions.py
  git commit -m "$(cat <<'EOF'
feat(area-2-state-gaze-separation): W2 — shot_staging v13 (Producer) atomic

prompts/_base/shot_staging/13.<TS>/:
- system.md: line 115-124 gaze section 전면 rewrite (3 field 의미 + Gate 2 정합 placeholder only)
- schema.json: character_angles[].items 안 gaze_direction_kind enum (8 entries) +
  gaze_target_id string (oneOf conditional, kind 별 pattern branch: ^C\\d{2,3}$ /
  ^(P|B)\\d{2,3}$ / null) + subject_state enum (4 entries) 신설.
  gaze_target field 완전 제거 (deprecated_* / legacy_* archival 금지).

3-way sync (Area #1 W2 패턴 정합):
- step_manifest.py:599 schema_version 4 → 5
- version_registry.py:33 shot_staging 2.5.0 → 2.6.0
- version_registry.py:122 prompt_dependency v12 → v13

test_shot_staging_v13_schema.py: 신규 — directory + schema + version sync 검증.
test_prompt_versions.py: v12/2.5.0/4 hardcoded → v13/2.6.0/5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  ```

---

## W3: semantic_contract_router rewrite (Patch B-min 재진입)

`IMMOBILIZED_GAZE` 제거 + Rule 1 source 전환 (gaze_target → subject_state).

**Files:**
- Modify: `backend/app/modules/semantic_contract_router.py:22, 78-107`
- Modify/Create: `backend/tests/unit/test_semantic_contract_router.py` (기존 있으면 갱신, 없으면 신설)

### W3.1: Write failing tests — router subject_state source

- [ ] Edit (or create) `backend/tests/unit/test_semantic_contract_router.py` — 신 source string 검증 + IMMOBILIZED_STATES helper import 검증:

```python
"""Area #2 W3 — semantic_contract_router subject_state source (Patch B-min 재진입)."""
import pytest
from app.modules.semantic_contract_router import build_semantic_contract


def test_rule_1_reads_subject_state_dead():
    contract = build_semantic_contract(
        shot_staging={
            "character_angles": [
                {"character": "Alpha", "angle": "back_to_camera", "body_pose": "lying",
                 "gaze_direction_kind": "closed_eyes", "subject_state": "dead"},
            ],
        },
        render_prompt_card=None,
        visible_entities=[{"name": "Alpha", "short_id": "C01", "entity_type": "character"}],
    )
    assert contract.primary_mode == "immobilized"
    assert contract.immobilized_entity_ids == ("C01",)
    # source string = subject_state (Q5 closure)
    sources = [e["source"] for e in contract.evidence]
    assert "shot_staging.character_angles.subject_state" in sources
    # 폐기: legacy source 없음
    assert "shot_staging.character_angles.gaze_target" not in sources


def test_rule_1_reads_subject_state_unconscious():
    contract = build_semantic_contract(
        shot_staging={
            "character_angles": [
                {"character": "Beta", "gaze_direction_kind": "closed_eyes", "subject_state": "unconscious",
                 "angle": "facing_camera", "body_pose": "slumped"},
            ],
        },
        render_prompt_card=None,
        visible_entities=[{"name": "Beta", "short_id": "C02", "entity_type": "character"}],
    )
    assert contract.primary_mode == "immobilized"
    assert "C02" in contract.immobilized_entity_ids


def test_rule_1_skip_alive():
    contract = build_semantic_contract(
        shot_staging={
            "character_angles": [
                {"character": "Gamma", "gaze_direction_kind": "camera", "subject_state": "alive",
                 "angle": "facing_camera", "body_pose": "standing"},
            ],
        },
        render_prompt_card=None,
        visible_entities=[{"name": "Gamma", "short_id": "C03", "entity_type": "character"}],
    )
    assert contract.primary_mode == "none"
    assert contract.immobilized_entity_ids == ()


def test_immobilized_gaze_constant_removed():
    import app.modules.semantic_contract_router as router
    assert not hasattr(router, "IMMOBILIZED_GAZE")
```

### W3.2: Run tests (verify fail)

- [ ] Run:
  ```bash
  cd backend && pytest tests/unit/test_semantic_contract_router.py -v
  ```
  Expected: All FAIL (router still reads gaze_target / IMMOBILIZED_GAZE).

### W3.3: Edit `semantic_contract_router.py` — rewrite Rule 1

- [ ] Edit `backend/app/modules/semantic_contract_router.py`:

  - Line 22: 제거 `IMMOBILIZED_GAZE: frozenset[str] = frozenset({"dead", "unconscious", "severely_injured"})`
  - Line 9 docstring: `Patch B-min 영역 (Rule 1 = gaze_target, Rule 2 = character_state)` → `Patch B-min wiring + Area #2 Q5 closure (Rule 1 = subject_state, Rule 2 = character_state)`
  - Line 78 docstring: `Rule 1 — gaze_target ∈ IMMOBILIZED_GAZE → immobilized.` → `Rule 1 — subject_state ∈ IMMOBILIZED_STATES → immobilized.`
  - Line 94-107: rewrite Rule 1 loop:

```python
# imports (top)
from app.core.subject_state import is_immobilized_state

# Rule 1
for entry in (shot_staging or {}).get("character_angles", []) or []:
    state = entry["subject_state"]            # required by v13 schema, KeyError = schema violation (Gate 4 fail-fast)
    if not is_immobilized_state(state):
        continue
    sid = name_to_sid.get(entry.get("character") or "")
    if not sid:
        continue
    immobilized.add(sid)
    source_state_map[sid] = state
    evidence_list.append({
        "source": "shot_staging.character_angles.subject_state",  # Area #2 Q5
        "entity_id": sid,
        "value": state,
    })
```

### W3.4: Run tests (verify pass)

- [ ] Run:
  ```bash
  cd backend && pytest tests/unit/test_semantic_contract_router.py -v
  ```
  Expected: All PASS.

- [ ] Run broader regression (router callers):
  ```bash
  cd backend && pytest tests/ -k "semantic_contract_router or sanitiz" -v
  ```
  Expected: PASS (existing scene_image_pipeline / coordinator wiring 무영향 — interface 동일).

### W3.5: Commit W3 (atomic)

- [ ] Stage + commit:
  ```bash
  git add backend/app/modules/semantic_contract_router.py \
          backend/tests/unit/test_semantic_contract_router.py
  git commit -m "$(cat <<'EOF'
feat(area-2-state-gaze-separation): W3 — semantic_contract_router rewrite (Patch B-min 재진입)

Q5 closure 의 핵심 — IMMOBILIZED_GAZE / gaze_target read 폐기.

semantic_contract_router.py:
- 폐기: IMMOBILIZED_GAZE frozenset (line 22)
- 폐기: Rule 1 source "shot_staging.character_angles.gaze_target"
- 신설: from app.core.subject_state import is_immobilized_state
- rewrite: Rule 1 = entry["subject_state"] read + is_immobilized_state check
- 신설 source: "shot_staging.character_angles.subject_state"
- docstring update: Patch B-min wiring + Area #2 Q5 closure

Rule 2 (character_state element via render_prompt_card) 무영향 — 별도 차원.
SemanticContract dataclass interface 동일 (caller 무영향).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  ```

---

## W4: image_steps state-variant generation (STATE_DESCRIPTIONS 폐기)

`CharacterStateVariantStep` 안 `STATE_DESCRIPTIONS` dict 폐기 + helper `get_visual_descriptor` 사용 + `gaze_target` read → `subject_state` read.

**Files:**
- Modify: `backend/app/core/steps/image_steps.py` (line 638-642 STATE_DESCRIPTIONS / 665 gaze read / 802 prompt format / 894 verify_completion)
- Modify/Create: `backend/tests/unit/test_character_state_variant_step.py`

### W4.1: Write failing tests

- [ ] Create (or update) `backend/tests/unit/test_character_state_variant_step.py`:

```python
"""Area #2 W4 — CharacterStateVariantStep helper migration."""
import pytest


class TestStateDescriptionsRemoved:
    def test_state_descriptions_constant_removed(self):
        from app.core.steps.image_steps import CharacterStateVariantStep
        assert not hasattr(CharacterStateVariantStep, "STATE_DESCRIPTIONS")


class TestHelperImport:
    def test_uses_subject_state_helper(self):
        import app.core.steps.image_steps as image_steps
        # subject_state helper import 확인
        source = image_steps.__file__
        with open(source) as f:
            content = f.read()
        assert "from app.core.subject_state import" in content
        assert "get_visual_descriptor" in content or "is_immobilized_state" in content
```

### W4.2: Run tests (verify fail)

- [ ] Run:
  ```bash
  cd backend && pytest tests/unit/test_character_state_variant_step.py -v
  ```
  Expected: FAIL.

### W4.3: Edit `image_steps.py` — STATE_DESCRIPTIONS 폐기

- [ ] Edit `backend/app/core/steps/image_steps.py`:

  - Top of file: add `from app.core.subject_state import is_immobilized_state, get_visual_descriptor`
  - Line 638-642: 제거 entire `STATE_DESCRIPTIONS = { ... }` block
  - Line 665: change `gaze = ca.get("gaze_target", ""); if gaze in self.STATE_DESCRIPTIONS:` → `state = ca["subject_state"]; if is_immobilized_state(state):`
  - Line 667-669: change `affected.setdefault(name, set()).add(gaze)` → `affected.setdefault(name, set()).add(state)`
  - Line 802 부근 (prompt format): change `self.STATE_DESCRIPTIONS[state_type]` → `get_visual_descriptor(state_type)`
  - Line 867 docstring: `dead/severely_injured/unconscious gaze_target 기준` → `dead/severely_injured/unconscious subject_state 기준`
  - Line 894: change `gaze = ca.get("gaze_target", ""); if gaze in (...)` → `state = ca["subject_state"]; if is_immobilized_state(state):`

### W4.4: Run tests (verify pass)

- [ ] Run:
  ```bash
  cd backend && pytest tests/unit/test_character_state_variant_step.py tests/ -k "character_state_variant or image_steps" -v
  ```
  Expected: PASS.

### W4.5: Commit W4 (atomic)

- [ ] Stage + commit:
  ```bash
  git add backend/app/core/steps/image_steps.py backend/tests/unit/test_character_state_variant_step.py
  git commit -m "$(cat <<'EOF'
feat(area-2-state-gaze-separation): W4 — image_steps state-variant generation (STATE_DESCRIPTIONS 폐기)

Q6 closure — image_steps CharacterStateVariantStep 안 hardcoded STATE_DESCRIPTIONS dict
폐기 + helper get_visual_descriptor 사용 + gaze_target read → subject_state read.

image_steps.py:
- 폐기: STATE_DESCRIPTIONS dict (line 638-642)
- 신설: from app.core.subject_state import is_immobilized_state, get_visual_descriptor
- 라인 665: gaze_target read → subject_state read + is_immobilized_state check
- 라인 802: STATE_DESCRIPTIONS lookup → get_visual_descriptor(state_type)
- 라인 894: 동일 migration (verify_completion)
- docstring (line 867): "gaze_target 기준" → "subject_state 기준"

state-variant reference image 생성 동일 결과 (3 prose entries dead/severely_injured/
unconscious helper 이동 — wording 동일).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  ```

---

## W5: scene ref + asset readiness + detail state mirror

4 site migration (asset_readiness, scene_reference_service ×2, detail_steps, render_prompt_card docstring).

**Files:**
- Modify: `backend/app/core/asset_readiness.py:435`
- Modify: `backend/app/services/scene_reference_service.py:932, 987` (2 site)
- Modify: `backend/app/core/steps/detail_steps.py:251-310, 305, 2255`
- Modify: `backend/app/core/steps/render_prompt_card.py:1972, 2021` (docstring + state_variant_chars derivation)

### W5.1: Write failing tests

- [ ] Create `backend/tests/integration/test_w5_state_migration.py`:

```python
"""Area #2 W5 — 4 site state migration verification."""
import pytest


def test_asset_readiness_reads_subject_state():
    import app.core.asset_readiness as ar
    source = open(ar.__file__).read()
    # legacy gaze_target read 폐기 verify
    assert 'ca.get("gaze_target"' not in source
    # subject_state helper 사용 verify
    assert "is_immobilized_state" in source


def test_scene_reference_service_reads_subject_state():
    import app.services.scene_reference_service as srs
    source = open(srs.__file__).read()
    assert 'ca.get("gaze_target"' not in source
    assert "is_immobilized_state" in source


def test_detail_steps_reads_subject_state():
    import app.core.steps.detail_steps as ds
    source = open(ds.__file__).read()
    # _STATE_VARIANT_GAZE_VALUES 폐기
    assert "_STATE_VARIANT_GAZE_VALUES" not in source
    assert "is_immobilized_state" in source


def test_render_prompt_card_docstring_sync():
    import app.core.steps.render_prompt_card as rpc
    source = open(rpc.__file__).read()
    # docstring 안 "staging gaze_target" 언급 폐기 또는 subject_state 명시
    # 정확한 wording 은 implementation 시 grep 후 결정
    assert "staging gaze_target" not in source or "subject_state" in source
```

### W5.2: Run tests (verify fail)

- [ ] Run:
  ```bash
  cd backend && pytest tests/integration/test_w5_state_migration.py -v
  ```
  Expected: FAIL.

### W5.3: Migrate `asset_readiness.py:435`

- [ ] Edit `backend/app/core/asset_readiness.py`:

  - Top: add `from app.core.subject_state import is_immobilized_state`
  - Line 435 부근: change
    ```python
    gaze = ca.get("gaze_target", "")
    if gaze in ("dead", "severely_injured", "unconscious"):
        ...
    ```
    → 
    ```python
    state = ca["subject_state"]
    if is_immobilized_state(state):
        ...
    ```

### W5.4: Migrate `scene_reference_service.py` (line 932, 987)

- [ ] Edit `backend/app/services/scene_reference_service.py`:

  - Top: add `from app.core.subject_state import is_immobilized_state`
  - Line 932: change `if ca.get("gaze_target", "") in ("unconscious", "dead", "severely_injured"):` → `if is_immobilized_state(ca["subject_state"]):`
  - Line 987: change `gaze = ca.get("gaze_target", "")` + literal compare → `state = ca["subject_state"]` + `is_immobilized_state(state)`
  - 주변 docstring (line 961-985 부근) 동일 wording sync (`gaze_target 이 unconscious/...` → `subject_state 가 unconscious/...`)

### W5.5: Migrate `detail_steps.py`

- [ ] Edit `backend/app/core/steps/detail_steps.py`:

  - Top: add `from app.core.subject_state import is_immobilized_state`
  - Line 251-310 (`_detect_state_variant_chars` 또는 유사 function): replace
    ```python
    _STATE_VARIANT_GAZE_VALUES = ("unconscious", "dead", "severely_injured")
    ...
    if ca.get("gaze_target") not in _STATE_VARIANT_GAZE_VALUES:
        continue
    ```
    → 폐기 `_STATE_VARIANT_GAZE_VALUES` + helper check:
    ```python
    if not is_immobilized_state(ca["subject_state"]):
        continue
    ```
  - Line 305 (line 251-310 안 정확한 위치 grep 후 결정): 동일 migration
  - Line 2255: change `_gaze = a.get('gaze_target', '')` → `_state = a["subject_state"]` + 사용처 update
  - Line 280-285 docstring: `staging.character_angles 의 gaze_target 이 dead/...` → `subject_state 가 dead/...`

### W5.6: Migrate `render_prompt_card.py` docstring + state_variant_chars derivation

- [ ] Grep actual derivation site:
  ```bash
  grep -n "state_variant_chars\|gaze_target" backend/app/core/steps/render_prompt_card.py | head -20
  ```

- [ ] Edit:
  - Line 1972 docstring: `(Optional[Set[character_id]]) — staging gaze_target` → `(Optional[Set[character_id]]) — staging subject_state`
  - Line 2021 comment: `staging gaze_target dead/unconscious 인물의` → `staging subject_state dead/unconscious 인물의`
  - state_variant_chars derivation (실제 grep verify site): `ca.get("gaze_target")` → `ca["subject_state"]` (Gate 4 fail-fast, schema required) + literal compare → `is_immobilized_state(ca["subject_state"])`

### W5.7: Run tests (verify pass)

- [ ] Run:
  ```bash
  cd backend && pytest tests/integration/test_w5_state_migration.py tests/ -k "asset_readiness or scene_reference or detail_steps or render_prompt_card" -v
  ```
  Expected: PASS.

### W5.8: Commit W5 (atomic)

- [ ] Stage + commit:
  ```bash
  git add backend/app/core/asset_readiness.py \
          backend/app/services/scene_reference_service.py \
          backend/app/core/steps/detail_steps.py \
          backend/app/core/steps/render_prompt_card.py \
          backend/tests/integration/test_w5_state_migration.py
  git commit -m "$(cat <<'EOF'
feat(area-2-state-gaze-separation): W5 — scene ref + asset readiness + detail state mirror

4 site migration — gaze_target literal read → subject_state helper-based enum check.

asset_readiness.py:435: gaze_target → is_immobilized_state(subject_state)
scene_reference_service.py:932, 987: 동일 migration + 주변 docstring sync
detail_steps.py:
- 폐기: _STATE_VARIANT_GAZE_VALUES tuple (line 251-310)
- _detect_state_variant_chars (또는 유사 fn): is_immobilized_state 도입
- line 2255: gaze_target read → subject_state read
- docstring sync
render_prompt_card.py:
- line 1972 docstring: staging gaze_target → staging subject_state
- line 2021 comment + derivation: subject_state read

helper imports: from app.core.subject_state import is_immobilized_state

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  ```

---

## W6: prompt injection consumers (scene_consistency_step + detail_steps prose)

raw `gaze_target` injection → enum-based prose 변환 (Gate 1 정합 — code 가 의미 재판정 X).

**Files:**
- Modify: `backend/app/core/steps/scene_consistency_step.py:741-743`
- Modify: `backend/app/core/steps/detail_steps.py` (angle line prose, line 2255 부근 + sweep)

### W6.1: Write failing tests

- [ ] Create `backend/tests/integration/test_w6_prompt_injection.py`:

```python
"""Area #2 W6 — prompt injection raw gaze_target → enum-based prose."""
import pytest


def test_scene_consistency_step_no_raw_gaze_injection():
    import app.core.steps.scene_consistency_step as scs
    source = open(scs.__file__).read()
    # raw f", eyes→{_gaze}" 패턴 폐기
    assert "eyes→{_gaze}" not in source
    assert "eyes→{" not in source  # broader catch
    # 신 prose: enum-based
    assert "gaze_direction_kind" in source or "subject_state" in source


def test_detail_steps_angle_lines_no_raw_gaze():
    import app.core.steps.detail_steps as ds
    source = open(ds.__file__).read()
    # angle line prose 안 raw gaze_target substitution 폐기
    # (정확한 패턴은 grep 후 확정, 단 raw gaze_target literal 0)
    assert "_gaze = a.get('gaze_target'" not in source
    assert '_gaze = a.get("gaze_target"' not in source
```

### W6.2: Run tests (verify fail)

- [ ] Run:
  ```bash
  cd backend && pytest tests/integration/test_w6_prompt_injection.py -v
  ```
  Expected: FAIL.

### W6.3: Edit `scene_consistency_step.py:741-743` — raw injection 폐기

- [ ] Edit `backend/app/core/steps/scene_consistency_step.py` line 738-743 부근:

  현재:
  ```python
  for a in char_angles:
      _cn = a.get("character", "")
      _angle = a.get("angle", "")
      _pose = a.get("body_pose", "")
      _gaze = a.get("gaze_target", "")
      _gaze_str = f", eyes→{_gaze}" if _gaze else ""
      user_prompt += f"  인물 배치: {_cn} — {_angle}, {_pose}{_gaze_str}\n"
  ```

  →

  ```python
  for a in char_angles:
      _cn = a.get("character", "")
      _angle = a.get("angle", "")
      _pose = a.get("body_pose", "")
      _kind = a["gaze_direction_kind"]   # required by v13 schema (Gate 4 fail-fast)
      _target_id = a.get("gaze_target_id")   # conditional null OK
      _state = a["subject_state"]            # required (Gate 4 fail-fast)
      # enum-based prose — code 가 의미 재판정 X (Gate 1)
      _gaze_str = f", gaze={_kind}" + (f"→{_target_id}" if _target_id else "")
      _state_str = f", state={_state}" if _state != "alive" else ""
      user_prompt += f"  인물 배치: {_cn} — {_angle}, {_pose}{_gaze_str}{_state_str}\n"
  ```

  ⚠️ raw enum literal 그대로 prose 안 노출 — code 가 의미 재판정 안 함 (i.e. "looks_at_character" → "보고 있음" prose 변환 같은 semantic translation 금지). LLM consumer 가 enum literal 해석.

### W6.4: Edit `detail_steps.py` angle lines (line 2255 부근)

- [ ] Grep actual angle prose injection sites:
  ```bash
  grep -n "gaze_target\|_gaze" backend/app/core/steps/detail_steps.py | head -10
  ```

- [ ] line 2255 부근: 동일 패턴 enum-based prose 변환:

  현재:
  ```python
  _gaze = a.get('gaze_target', '')
  ```

  →

  ```python
  _kind = a["gaze_direction_kind"]   # required by v13 schema (Gate 4 fail-fast)
  _target_id = a.get("gaze_target_id")   # conditional null OK
  _state = a["subject_state"]            # required (Gate 4 fail-fast)
  ```

  사용처 (prose format) 동일 migration — raw value 직접 substitution 만, semantic translation 금지.

### W6.5: Run tests (verify pass)

- [ ] Run:
  ```bash
  cd backend && pytest tests/integration/test_w6_prompt_injection.py tests/ -k "scene_consistency or detail_steps" -v
  ```
  Expected: PASS.

### W6.6: Commit W6 (atomic)

- [ ] Stage + commit:
  ```bash
  git add backend/app/core/steps/scene_consistency_step.py \
          backend/app/core/steps/detail_steps.py \
          backend/tests/integration/test_w6_prompt_injection.py
  git commit -m "$(cat <<'EOF'
feat(area-2-state-gaze-separation): W6 — prompt injection consumers (raw gaze_target 폐기)

raw value direct substitution → enum-based prose (gaze_direction_kind / gaze_target_id /
subject_state raw literal 그대로 노출, code 의미 재판정 X, Gate 1 정합).

scene_consistency_step.py:741-743:
- 폐기: `eyes→{_gaze}` raw injection
- 신설: `gaze={kind}` + (target_id 있으면 `→{target_id}`) + (state alive 아니면 `state={state}`)

detail_steps.py (line 2255 + sweep):
- 폐기: _gaze = a.get('gaze_target', '')
- 신설: _kind / _target_id / _state 3 raw read + prose format

LLM consumer 가 enum literal 해석 — code semantic translation (looks_at_character →
"보고 있음" 같은) 금지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  ```

---

## W7: shot_visibility Path 1 (Area #3 boundary 명확)

shot_visibility Path 1 (line 321-393) — `gaze_target` direction read → `gaze_direction_kind == "looks_at_character"` + `gaze_target_id` read. Path 2 (Korean lexicon / 4 regex / camera_direction offscreen) 무영향 (Area #3 boundary).

**Files:**
- Modify: `backend/app/modules/pipeline/shot_visibility.py:321-393`
- Modify/Create: `backend/tests/unit/test_shot_visibility_path1.py`

### W7.1: Write failing tests

- [ ] Create `backend/tests/unit/test_shot_visibility_path1.py`:

```python
"""Area #2 W7 — shot_visibility Path 1 (gaze_direction_kind + gaze_target_id) read.
Path 2 (Korean lexicon / regex) = Area #3 boundary, 본 test scope 외."""
import pytest


def test_path1_reads_gaze_direction_kind():
    import app.modules.pipeline.shot_visibility as sv
    source = open(sv.__file__).read()
    # legacy gaze_target direction read 폐기
    # 단 Path 2 (Korean lexicon) 안 gaze_target 잔존 OK (Area #3 scope)
    # Path 1 안 gaze_target read 만 폐기 — exact 매치
    # 신 read: gaze_direction_kind
    assert "gaze_direction_kind" in source
    # dispatch helper 사용
    assert "resolve_visible_character_target" in source or "gaze_target_id" in source


def test_path1_uses_helper_dispatch():
    import app.modules.pipeline.shot_visibility as sv
    source = open(sv.__file__).read()
    # name matching / canonical name lookup 금지 (Q7 closure)
    # Path 1 안 character canonical name 사용 X (gaze_target_id C## 만)
    # (단 Path 2 = Area #3 boundary, name matching 잔존 OK)
    assert "from app.core.gaze_direction import" in source


# 실제 동작 test (Path 1 input → output) — fixture 신설 후 확장
```

### W7.2: Run tests (verify fail)

- [ ] Run:
  ```bash
  cd backend && pytest tests/unit/test_shot_visibility_path1.py -v
  ```
  Expected: FAIL.

### W7.3: Edit `shot_visibility.py:321-393` — Path 1 rewrite

- [ ] Read current Path 1:
  ```bash
  sed -n '320,395p' backend/app/modules/pipeline/shot_visibility.py
  ```

- [ ] Edit `backend/app/modules/pipeline/shot_visibility.py`:

  - Top: add `from app.core.gaze_direction import resolve_visible_character_target`
  - Line 321-322 docstring: `character_angles[].gaze_target` → `character_angles[].gaze_direction_kind + gaze_target_id`
  - Line 388-393 Path 1 loop: 

    현재 (대략):
    ```python
    for ca in char_angles:
        gt = (ca.get("gaze_target") or "").strip()
        if not gt: continue
        # canonical name 매칭 (현재 implementation)
        ...
    ```

    →

    ```python
    from app.core.gaze_direction import validate_pairing, resolve_visible_character_target

    for ca in char_angles:
        kind = ca["gaze_direction_kind"]   # required by v13 schema (Gate 4 fail-fast)
        if kind != "looks_at_character":
            continue
        target_id = ca.get("gaze_target_id")
        # Defense in depth: schema oneOf 가 production runtime missing 차단하지만 fixture/우회 경로 missing 시 AppError fail-fast (Gate 4)
        validate_pairing(kind, target_id, where="shot_visibility.path1")
        # 이 시점 target_id 는 ^C\d{2,3}$ 매치 보장 (validate_pairing 안 shape validation)
        # Q7 dispatch helper — structural validation only
        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
        # ... visible_entity_ids 추가
    ```

  ⚠️ **Path 2 (Korean lexicon / 4 regex / camera_direction offscreen) 무영향 의무** — 본 wave 안 변경 X. Area #3 scope.

### W7.4: Verify Path 2 무영향

- [ ] Diff verify:
  ```bash
  git diff backend/app/modules/pipeline/shot_visibility.py | grep -E "^[+-]" | head -50
  ```
  
  changes 가 Path 1 region (line 321-393) 안에만 있는지 확인. Path 2 region (other line ranges) 무변경 verify.

### W7.5: Run tests (verify pass)

- [ ] Run:
  ```bash
  cd backend && pytest tests/unit/test_shot_visibility_path1.py tests/ -k "shot_visibility" -v
  ```
  Expected: PASS (Path 1 test PASS + Path 2 existing test 무영향).

### W7.6: Commit W7 (atomic)

- [ ] Stage + commit:
  ```bash
  git add backend/app/modules/pipeline/shot_visibility.py \
          backend/tests/unit/test_shot_visibility_path1.py
  git commit -m "$(cat <<'EOF'
feat(area-2-state-gaze-separation): W7 — shot_visibility Path 1 (Q7 dispatch helper)

Path 1 (line 321-393) — gaze_target direction read → gaze_direction_kind +
gaze_target_id read. Q7 dispatch helper (resolve_visible_character_target)
사용 — structural validation only (C## shape + visible_set membership).
name matching / canonical name lookup 금지 (Gate 1).

Path 2 (Korean lexicon / 4 regex / camera_direction offscreen) 무영향 —
Area #3 boundary 존중 (본 wave 안 변경 X).

shot_visibility.py:
- import: from app.core.gaze_direction import resolve_visible_character_target
- 폐기: ca.get("gaze_target") canonical name 매칭 (Path 1 부분)
- 신설: kind == "looks_at_character" + target_id read + helper dispatch
- docstring sync (line 321-322): gaze_target → gaze_direction_kind + gaze_target_id

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  ```

---

## W8: test cascade full sweep + residue gate verify

Area #1 W7 패턴 정합 (cascade pattern 단위 sub-wave 분할). 모든 fixture v13 정합 + residue gate 0 hits + baseline regression 0.

**Files:**
- Modify: all test fixtures with shot_staging shape (grep verify)
- Migrate: gaze_target literal compare tests → subject_state enum check
- Remove (or migrate): IMMOBILIZED_GAZE test → IMMOBILIZED_STATES helper test
- Remove (or migrate): STATE_DESCRIPTIONS test → SUBJECT_STATE_VISUAL_DESCRIPTOR helper test
- Create: `backend/tests/integration/test_state_and_gaze_e2e.py` (신규 e2e)

### W8a: fixture migration (모든 shot_staging fixture 갱신)

- [ ] Grep all fixtures with shot_staging shape:
  ```bash
  grep -rln '"gaze_target"' backend/tests/fixtures/ backend/tests/integration/ backend/tests/unit/
  ```

- [ ] For each fixture file: change shape — `"gaze_target": "<value>"` → `"gaze_direction_kind": "<kind>", "gaze_target_id": "<id_or_null>", "subject_state": "<state>"`. Migration heuristic:
  - `"gaze_target": "camera"` → `"gaze_direction_kind": "camera", "subject_state": "alive"` (state default 명시 의무)
  - `"gaze_target": "<character_name>"` → `"gaze_direction_kind": "looks_at_character", "gaze_target_id": "C##", "subject_state": "alive"` (manual decision per fixture, ID 매칭 의무)
  - `"gaze_target": "dead"` → `"gaze_direction_kind": "closed_eyes", "subject_state": "dead"` (또는 적절한 kind)
  - `"gaze_target": "unconscious"` / `"severely_injured"` → 동일
  - `"gaze_target": "closed"` → `"gaze_direction_kind": "closed_eyes", "subject_state": "alive"`

- [ ] Commit W8a:
  ```bash
  git add backend/tests/fixtures/ backend/tests/integration/ backend/tests/unit/
  git commit -m "test(area-2-state-gaze-separation): W8a — fixture migration to shot_staging v13 shape"
  ```

### W8b: rename / sub-field assertion / IMMOBILIZED_GAZE 폐기 test

- [ ] Grep `IMMOBILIZED_GAZE` test references:
  ```bash
  grep -rln "IMMOBILIZED_GAZE\|STATE_DESCRIPTIONS\|_STATE_VARIANT_GAZE_VALUES" backend/tests/
  ```

- [ ] For each match: 
  - If test 자체가 폐기 constant 검증 → 폐기 marker (test 통째 또는 sub-test 폐기)
  - If test 가 의미 검증 → IMMOBILIZED_STATES (helper) 또는 SUBJECT_STATE_VISUAL_DESCRIPTOR (helper) test 로 migration

- [ ] Commit W8b:
  ```bash
  git add backend/tests/
  git commit -m "test(area-2-state-gaze-separation): W8b — rename + 폐기 IMMOBILIZED_GAZE/STATE_DESCRIPTIONS test cascade"
  ```

### W8c: 신규 e2e test + obsolete file 폐기

- [ ] Create `backend/tests/integration/test_state_and_gaze_e2e.py`:

```python
"""Area #2 W8c — state and gaze e2e (producer → router → state-variant generation)."""
import pytest


def test_e2e_router_immobilized_via_subject_state():
    """semantic_contract_router Rule 1 subject_state read 정합."""
    from app.modules.semantic_contract_router import build_semantic_contract
    
    contract = build_semantic_contract(
        shot_staging={
            "character_angles": [
                {"character": "Alpha", "angle": "back_to_camera", "body_pose": "lying",
                 "gaze_direction_kind": "closed_eyes", "subject_state": "dead"},
            ],
        },
        render_prompt_card=None,
        visible_entities=[{"name": "Alpha", "short_id": "C01", "entity_type": "character"}],
    )
    assert contract.primary_mode == "immobilized"


def test_e2e_state_variant_uses_helper():
    """CharacterStateVariantStep 가 subject_state read + helper get_visual_descriptor 사용."""
    from app.core.subject_state import get_visual_descriptor
    desc = get_visual_descriptor("dead")
    assert "motionless" in desc or len(desc) > 10
```

- [ ] Identify obsolete test files (legacy gaze_target literal compare, helper-side legacy import):
  ```bash
  grep -rln 'from app.modules.semantic_contract_router import IMMOBILIZED_GAZE' backend/tests/
  ```
  → 모든 match 폐기 또는 IMMOBILIZED_STATES import 로 migration.

- [ ] Commit W8c:
  ```bash
  git add backend/tests/integration/test_state_and_gaze_e2e.py backend/tests/
  git commit -m "test(area-2-state-gaze-separation): W8c — e2e + obsolete cleanup"
  ```

### W8d: residue gate 0 hits verify

- [ ] Run residue gate commands (spec §7.2.1):

  ```bash
  # E1
  grep -rEn '\bgaze_target([^_a-zA-Z0-9]|$)|\bIMMOBILIZED_GAZE\b|\bSTATE_DESCRIPTIONS\b|\b_STATE_VARIANT_GAZE_VALUES\b' backend/app/
  
  # E1b
  grep -rn 'gaze.*dead\|gaze.*unconscious\|gaze.*severely_injured' backend/app/
  
  # E2
  grep -rEn '\bgaze_target([^_a-zA-Z0-9]|$)' prompts/_base/shot_staging/13.*
  
  # E3
  grep -rEn '"gaze_target"([^_]|$)|get\("gaze_target"\)|shot_staging\.character_angles\.gaze_target' backend/
  ```
  Expected: 0 hits (또는 known exceptions only — `_archive/`, historical archival test docstring, v12 directory 자체).

- [ ] Run full pytest baseline (Area #1 패턴 정합, `-x` 금지 — 신규 fail 0 verify 위해 full run):
  ```bash
  cd backend && pytest tests/ -q --ignore=tests/_audit_outputs/ 2>&1 | tail -30
  ```
  Expected: pre-existing baseline fail set 외 Area #2 신규 fail 0. baseline 비교 의무.

- [ ] If residue 발견 → 추가 fix-up sub-commit per cascade pattern.

- [ ] Commit W8d (residue verify completion):
  ```bash
  git commit --allow-empty -m "test(area-2-state-gaze-separation): W8d — residue gate verify 0 hits + baseline regression 0"
  ```
  
  (또는 cleanup commit 안 fix-up 있으면 그 안 흡수)

---

## W9: closure (roadmap amend + Codex range review + push gate)

⚠️ **memory paths 는 repo 외부** (`/Users/manta/.claude/projects/.../memory/`) — git commit scope 외. W9.3-W9.5 = Claude memory system 안 작성 (외부 task), W9.6 git commit = roadmap (spec docs) 만.

**Files:**
- Modify: `docs/superpowers/specs/2026-05-16-track-b-semantic-debt-roadmap-design.md` (§5.2 wording amend + §11 closed reclassification)

**External (memory system, NOT git commit)**:
- `memory/session_20260512_patch_b_implementation.md` (Patch B-min closure wording 정정 — Claude memory tool)
- `memory/session_20260517_area_2_state_gaze_separation_closure.md` (closure memo 신설 — Claude memory tool)
- `memory/MEMORY.md` (index entry — Claude memory tool)

### W9.1: Track B roadmap §5.2 wording amend (git scope)

- [ ] Edit `docs/superpowers/specs/2026-05-16-track-b-semantic-debt-roadmap-design.md` §5.2:

  - line 308 enum 후보 `closed / off_screen / looks_at_entity / looks_at_object` → `closed_eyes / off_screen / looks_at_character / looks_at_object` (Area #2 Q1 결정 정합)
  - line 310 subject_state 후보 `alive_active / alive_immobilized / unconscious / dead / severely_injured` → `alive / unconscious / dead / severely_injured` (Area #2 Q2 결정 정합)
  - line 311-313 결정 의무 4 항목 → "Area #2 spec (commit `30b62f8`+) 안 closure" mark

### W9.2: Track B roadmap §11 closed reclassification (git scope)

- [ ] Edit §11 Closed Area 재분류 table:

  Area #2 row 추가 (또는 update):
  - Area #2 = closed (commit `<W9 closure commit hash>`, closure memo external reference)
  - Patch B-min row update: "wiring closed (commit f2682d7), G1 본체 Area #2 에서 해결 완료 (commit `<W9>`)"

### W9.3: Commit W9 (closure, git scope = roadmap only)

- [ ] Stage + commit:
  ```bash
  git add docs/superpowers/specs/2026-05-16-track-b-semantic-debt-roadmap-design.md
  git commit -m "$(cat <<'EOF'
docs(area-2-state-gaze-separation): W9 — closure (roadmap amend)

Track B roadmap §5.2 wording amend (Area #2 Q1+Q2 결정 정합):
- line 308 enum: closed/off_screen/looks_at_entity/looks_at_object →
  closed_eyes/off_screen/looks_at_character/looks_at_object
- line 310 subject_state: alive_active/alive_immobilized 폐기 → 4 entries

§11 Closed Area: Area #2 closed row 추가 + Patch B-min row update.

External (Claude memory system, NOT git): Patch B-min closure memo
wording 정정 + 신규 closure memo + MEMORY.md index update (별도 task).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  ```

### W9.4: External — Patch B-min closure memo wording 정정 (Claude memory tool, NOT git)

⚠️ memory file 은 `/Users/manta/.claude/projects/.../memory/` (repo 외부). Claude memory tool 로 작성 — git commit 무관.

- [ ] Update `session_20260512_patch_b_implementation.md` (memory):
  - "Patch B-min closed" → "Patch B-min wiring closed (gaze_target SOT), G1 본체 (gaze_target overload) Area #2 에서 해결 완료 (commit `<W3 commit hash>`)"
  - IMMOBILIZED_GAZE / Rule 1 source 변경 record 추가

### W9.5: External — Closure memo 신설 (Claude memory tool)

- [ ] Create `session_20260517_area_2_state_gaze_separation_closure.md` (memory):

```markdown
---
name: session-20260517-area-2-state-gaze-separation-closure
description: 2026-05-17 Area #2 (State / Gaze Separation) closure. W1-W9 atomic + Q1-Q7 closure + Codex iter <N> APPROVED. shot_staging v13 + helper 2 module + 8 consumer migration + Patch B-min Rule 1 rewrite.
metadata:
  type: project
  node_type: memory
  originSessionId: area-2-state-gaze-separation-w1-w9-execution
---

# 2026-05-17 Area #2 State / Gaze Separation closure 종합

**상태**: W1-W9 모두 완료. Codex range review APPROVED. push 대기 (사용자 명시).

## Push range (origin/main `<base>..<W9>`, <N> commit)
[git log oneline range]

## Spec / Plan reference
- Spec: `docs/superpowers/specs/2026-05-17-area-2-state-gaze-separation-design.md`
- Plan: `docs/superpowers/plans/2026-05-17-area-2-state-gaze-separation.md`

## Wave 별 종합
[W1-W9 각 wave 결과 record — file count / line / Codex iter / test result]

## Closure verify
[test counts / residue gates 0 / baseline regression / Patch B-min wording 정정 / roadmap amend]

## 함정 / Lessons learned
[본 session 발견 lesson — Area #1 carry 8 + 신규]

## 관련 메모
- 진입점 supersede: [[next_session_area_2_state_gaze_separation_brainstorm]] 해소
- 진입점 다음: [[next_session_area_3_visibility_physical_presence_brainstorm]] (Tier 1 #3)
- Track B roadmap: [[session_20260516_track_b_roadmap_closure]] §5.2 + §11 update
- Area #1 closure (pattern donor): [[session_20260516_area_1_id_outlook_reference_policy_sot_v1_closure]]
- Patch B-min closure wording 정정: [[session_20260512_patch_b_implementation]]
- 4 gate policy: [[feedback_llm_based_judgment]]
```

### W9.6: External — MEMORY.md index update (Claude memory tool)

- [ ] Add closure memo entry to `MEMORY.md` index (memory).
- [ ] 진입점 entry update — `next_session_area_2_state_gaze_separation_brainstorm` → 해소 mark + closure 참조.

### W9.7: Codex W1-W9 range review (사용자 manual paste)

- [ ] 사용자 명시 Codex range review (`git diff origin/main..HEAD` 또는 commit list).
- [ ] APPROVED 받기까지 wait. NEEDS_REVISION 시 fix-up commit + re-review.

### W9.8: Push gate (사용자 명시)

- [ ] 사용자 명시 push 승인 후:
  ```bash
  git push origin main
  ```

---

## Closure Criteria (모두 충족 시 Area #2 closed)

- [ ] W1-W9 모두 commit
- [ ] residue gate (E1 / E1b / E2 / E3 / D4) 모두 0 hits
- [ ] Codex W1-W9 range review APPROVED
- [ ] pre-existing baseline 외 신규 fail 0
- [ ] Track B roadmap §5.1 (Area #1 carry 정합 reconfirm) + §11 (Area #2 closed 재분류)
- [ ] Patch B-min memo wording 정정 (`[[session_20260512_patch_b_implementation]]`)
- [ ] 사용자 명시 push 승인

---

## End of plan

**Next area**: Tier 1 #3 (Visibility / Physical Presence, shot_visibility G3 본체) — 별도 spec/plan.
