#!/bin/bash
# 사용법: finish-task.sh <task_id> [team_short] [project_path]
# L1 마무리 스크립트: QC → 머지 → .done 생성 → task-timer end → notify-completion
set -euo pipefail

# ── task-2729+3 (Option A): finalize-only 판정 = 환경변수 우선(env-first) ──
# >>> task-2729+3 FINALIZE_ONLY_ARGPARSE_BEGIN
# 주 경로(primary): FINALIZE_ONLY=1 (env). 위치인자 --finalize-only 는 보조호환(secondary compat)일 뿐,
#   declarative 의존 없이 안전 finalize 를 트리거하는 정식 경로는 환경변수다.
# ★ bash array 미사용: old bash(≤3.2)+`set -u` 에서 빈 배열 인덱스 참조(${arr[0]:-})는 기본값 연산자를
#   써도 'unbound variable' 로 즉시 종료된다(PR#176~178 계열 HIGH 의 근본 원인). 인덱스 카운터(_pos_idx)
#   + case 로 배열 없이 위치무관 파싱한다 → 무인자 / `--finalize-only` 단독에서도 unbound 0.
# 위치무관(position-independent): --finalize-only 가 $1/$2/$3/$4 어디에 있어도 positional
#   인자(TASK_ID/TEAM_SHORT/PROJECT_PATH)·파생 BRANCH_NAME 오염 0.
_FINALIZE_ONLY=0
TASK_ID=""
TEAM_SHORT=""
PROJECT_PATH=""
_pos_idx=0
for _arg in "$@"; do
    if [ "$_arg" = "--finalize-only" ]; then
        _FINALIZE_ONLY=1   # 보조호환 플래그
    else
        case "$_pos_idx" in
            0) TASK_ID="$_arg" ;;
            1) TEAM_SHORT="$_arg" ;;
            2) PROJECT_PATH="$_arg" ;;
            # idx>=3 잉여 인자는 무시(오염 방지)
        esac
        _pos_idx=$((_pos_idx + 1))
    fi
done
# 주 경로: env FINALIZE_ONLY=1 → finalize-only 승격(위치인자 유무와 무관). 미설정 시 기존 동작 보존.
if [ "${FINALIZE_ONLY:-0}" = "1" ]; then _FINALIZE_ONLY=1; fi
# <<< task-2729+3 FINALIZE_ONLY_ARGPARSE_END
WORKSPACE="${WORKSPACE:-$HOME/workspace}"
EVENTS_DIR="$WORKSPACE/memory/events"
DONE_FILE="$EVENTS_DIR/${TASK_ID}.done"

# ── task-2726: worktree 격리 strict mode (feature flag default-off) ──
#   FINISH_TASK_WORKTREE_STRICT=1 일 때만 활성. 미설정/0 = 레거시 동작 불변(회귀 안전).
#   근거: shared main 1238 누적 dirty 가 모든 task .done/callback 차단 (diagnosis_finish_task_external_dirty_260603).
FINISH_TASK_WORKTREE_STRICT="${FINISH_TASK_WORKTREE_STRICT:-0}"

# task 가 worktree 를 기대하는지 판정 (시스템 task vs worktree task 구분).
#   worktree-base.json 마커 또는 task-timers.json worktree_path 존재 시 true(0). 순수 시스템 task 는 false(1).
_task_expects_worktree() {
    local tid="$1"
    [ -f "$EVENTS_DIR/${tid}.worktree-base.json" ] && return 0
    local wt
    wt=$(python3 -c "
import json, sys
workspace, tid = sys.argv[1], sys.argv[2]
try:
    d=json.load(open(workspace+'/memory/task-timers.json'))
    t=d.get('tasks',d).get(tid,{})
    print((t.get('worktree_path','') or '').strip())
except Exception:
    print('')
" "$WORKSPACE" "$tid" 2>/dev/null || echo "")
    [ -n "$wt" ] && return 0
    return 1
}

# scheduler/로컬 worktree 경로 해석. echo 절대경로 또는 빈 문자열.
_resolve_task_worktree() {
    local tid="$1"
    python3 -c "
import json, os, glob, re, sys
workspace, tid = sys.argv[1], sys.argv[2]
try:
    d=json.load(open(f'{workspace}/memory/task-timers.json'))
    t=d.get('tasks',d).get(tid,{})
    wt=(t.get('worktree_path','') or '').strip()
    if wt and os.path.isdir(wt) and wt!=workspace:
        print(wt); raise SystemExit
except SystemExit:
    raise
except Exception:
    pass
m=re.search(r'(\d+)', tid); num=m.group(1) if m else tid
for p in glob.glob(f'{workspace}/.worktrees/*{num}*'):
    if os.path.isdir(p): print(p); raise SystemExit
_home=os.path.expanduser('~')
for p in glob.glob(f'{_home}/.cokacdir/workspace/*/wt-*{num}*') + glob.glob(f'{_home}/.cokacdir/workspace/*/iso-*{num}*'):
    if os.path.isdir(p): print(p); raise SystemExit
try:
    c=open(f'{workspace}/memory/tasks/{tid}.md',encoding='utf-8').read()
    mm=re.search(r'##\s*worktree\s*:\s*(\S+)', c, re.IGNORECASE)
    if mm and os.path.isdir(mm.group(1)): print(mm.group(1)); raise SystemExit
except SystemExit:
    raise
except Exception:
    pass
print('')
" "$WORKSPACE" "$tid" 2>/dev/null || echo ""
}

# WORKTREE_UNRESOLVED 마커 발행 (shared main fallback 금지, fail-closed 직전 호출).
_emit_worktree_unresolved() {
    local tid="$1" reason="$2"
    python3 -c "
import json, time, sys
from pathlib import Path
events_dir, tid, reason = sys.argv[1], sys.argv[2], sys.argv[3]
Path(events_dir).mkdir(parents=True, exist_ok=True)
p=Path(events_dir)/(tid+'.worktree-unresolved.json')
p.write_text(json.dumps({'task_id':tid,'classification':'WORKTREE_UNRESOLVED','reason':reason,'ts':time.time()},ensure_ascii=False,indent=2))
" "$EVENTS_DIR" "$tid" "$reason" 2>/dev/null || true
    echo "[GIT-GATE][STRICT] WORKTREE_UNRESOLVED: $reason — shared main fallback 금지, fail-closed." >&2
}

# ── task-2712 FAILURE_CALLBACK_BEFORE_EXIT_GUARD: before-exit hook source ──
# 9 failure terminal state + CRASH_NO_EXIT_CODE 어떤 exit point 에서든 exit 전
# disk terminal marker 박제 보장 (.done = SUCCESS 전용 · exactly-one rule).
if [ -f "$WORKSPACE/scripts/harness/v36/before_exit_guard_hook.sh" ]; then
    # shellcheck source=/dev/null
    source "$WORKSPACE/scripts/harness/v36/before_exit_guard_hook.sh"
fi

# ── task-2571: stash lifecycle constants (Gemini Medium #3 carry-over) ──
readonly STASH_WARN_THRESHOLD=5
readonly STASH_QUARANTINE_HARD_LIMIT=100
readonly STASH_AUDIT_TIMEOUT_SEC=30

# ERR/EXIT trap: 비정상 종료 시에도 timer end 호출
_timer_ended=0
cleanup_timer() {
    if [ "$_timer_ended" -eq 0 ]; then
        _timer_ended=1
        # QC FAIL 종료 시에는 timer end를 호출하지 않음 (재시도 가능하므로)
        # .done이 생성된 후 오류 발생 시에만 timer end 호출
        if [ -f "$DONE_FILE" ]; then
            python3 "$WORKSPACE/memory/task-timer.py" end "$TASK_ID" 2>&1 || true
        fi
        [ "${TERMINAL_CALLBACK_ENABLED:-0}" = "1" ] && python3 "$WORKSPACE/scripts/harness/v36/terminal_state_callback.py" emit --task-id "$TASK_ID" --events-dir "$EVENTS_DIR" --workspace "$WORKSPACE" --done-file "$DONE_FILE" 2>/dev/null || true
    fi
}
# ── task-2712 §3.1.2 trap 3 line: EXIT/SIGINT/SIGTERM 모두 exit 전 marker ──
# EXIT trap 은 catch-all (inner hook 이 이미 fire 했으면 exactly-one rule 로 no-op,
# SUCCESS .done 존재 시도 no-op). SIGINT/SIGTERM 은 crash sentinel marker.
trap '_emit_failure_envelope CRASH_NO_EXIT_CODE "$TASK_ID" $? "trap_EXIT" supervisor_crash 2>/dev/null || true; cleanup_timer' EXIT
trap '_emit_failure_envelope CRASH_NO_EXIT_CODE "$TASK_ID" -2 "SIGINT" supervisor_crash 2>/dev/null || true; exit 130' INT
trap '_emit_failure_envelope CRASH_NO_EXIT_CODE "$TASK_ID" -15 "SIGTERM" supervisor_crash 2>/dev/null || true; exit 143' TERM

# ── task-2569 RC-3: stash lifecycle audit (회장 박제 사건 2026-05-13) ──
_STASH_AUDIT_BEFORE=$(git stash list 2>/dev/null | wc -l)
if [ "$_STASH_AUDIT_BEFORE" -gt "$STASH_WARN_THRESHOLD" ]; then
    echo "[WARN][task-2569] stash 누적 감지 ($_STASH_AUDIT_BEFORE개) — task-2568 박제 사건 재발 가능. git stash list 확인 권장." >&2
fi
# audit log 박제 (JSONL)
mkdir -p "$WORKSPACE/memory/logs"
python3 - "$_STASH_AUDIT_BEFORE" "${TASK_ID:-unknown}" <<'PYEOF' >> "$WORKSPACE/memory/logs/cleanup-audit.jsonl" 2>/dev/null || true
import json, sys, time
print(json.dumps({"ts": time.time(), "audit": "finish-task-start", "task_id": sys.argv[2], "stash_count": int(sys.argv[1]), "stash_origin_metadata": {"source": "finish-task", "caller_script": "finish-task.sh", "reason": "audit-pre-finish", "task_id": sys.argv[2]}}, ensure_ascii=False))
PYEOF
if [ -f "$WORKSPACE/scripts/stash_audit.py" ]; then
    python3 "$WORKSPACE/scripts/stash_audit.py" --workspace "$WORKSPACE" --json 2>/dev/null \
        | python3 - "$TASK_ID" "start" <<'PYEOF' >> "$WORKSPACE/memory/logs/cleanup-audit.jsonl" 2>/dev/null || true
import json, sys
from datetime import datetime, timezone
obj = json.load(sys.stdin)
task_id = sys.argv[1]
phase = sys.argv[2]
print(json.dumps({
    "ts_utc": datetime.now(timezone.utc).isoformat(),
    "audit": "stash-origin-audit",
    "phase": phase,
    "task_id": task_id,
    "summary": obj.get("summary", {}),
}, ensure_ascii=False))
PYEOF
fi

# 0-cancelled. .cancelled 마커 감지 시 .done 생성 차단 (task-2352)
CANCELLED_FILE="$EVENTS_DIR/${TASK_ID}.cancelled"
if [ -f "$CANCELLED_FILE" ]; then
    echo "[CANCELLED] task $TASK_ID는 취소된 작업입니다 (.cancelled 마커 발견). .done 생성 차단."
    # task-timer end는 호출 (cancelled 상태 마킹)
    python3 "$WORKSPACE/memory/task-timer.py" end "$TASK_ID" --qc-result CANCELLED 2>&1 || true
    _timer_ended=1
    exit 0
fi

# 0-stopmarker. task 파일 상단 STOP 마커 가드 (task-2352)
TASK_FILE_PRECHECK="$WORKSPACE/memory/tasks/${TASK_ID}.md"
if [ -f "$TASK_FILE_PRECHECK" ]; then
    HEAD3=$(head -3 "$TASK_FILE_PRECHECK" 2>/dev/null || true)
    if echo "$HEAD3" | grep -qE "CANCELLED|작업 취소됨"; then
        echo "[CANCELLED] task 파일 상단 STOP 마커 감지. .cancelled 생성 후 종료."
        : > "$CANCELLED_FILE" || true
        python3 -c "import json,sys; from datetime import datetime; json.dump({'task_id':'$TASK_ID','cancelled_at':datetime.now().isoformat(),'reason':'task-file STOP marker (auto)'}, open('$CANCELLED_FILE','w'))" 2>/dev/null || true
        python3 "$WORKSPACE/memory/task-timer.py" end "$TASK_ID" --qc-result CANCELLED 2>&1 || true
        _timer_ended=1
        exit 0
    fi
fi

# team_short 미지정 시 task-timers.json에서 자동 추출
if [ -z "$TEAM_SHORT" ]; then
    TEAM_SHORT=$(python3 -c "
import json
try:
    with open('$WORKSPACE/memory/task-timers.json') as f:
        data = json.load(f)
    tasks = data.get('tasks', data)
    t = tasks.get('$TASK_ID', {})
    print(t.get('team', ''))
except Exception:
    print('')
" 2>/dev/null || echo "")
fi

# PROJECT_PATH 워크트리 자동 인식 (task-2348)
# PROJECT_PATH가 비어있거나 WORKSPACE 자체인 경우, task-timers.json의 worktree_path로 자동 보정
if [ -z "$PROJECT_PATH" ] || [ "$PROJECT_PATH" = "$WORKSPACE" ]; then
    _AUTO_PROJECT_PATH=$(python3 -c "
import json, os

workspace = '$WORKSPACE'
task_id = '$TASK_ID'

# 1차: task-timers.json worktree_path
try:
    with open(f'{workspace}/memory/task-timers.json') as f:
        data = json.load(f)
    tasks = data.get('tasks', data)
    t = tasks.get(task_id, {})
    wt = (t.get('worktree_path', '') or '').strip()
    if wt and os.path.isdir(wt) and wt != workspace:
        print(wt)
        exit(0)
except Exception:
    pass

# 2차: task 파일에서 worktree 경로 파싱 (## worktree: /path 형식)
import re
task_file = f'{workspace}/memory/tasks/{task_id}.md'
try:
    with open(task_file, encoding='utf-8') as f:
        content = f.read()
    m = re.search(r'##\s*worktree\s*:\s*(\S+)', content, re.IGNORECASE)
    if m:
        wt = m.group(1).strip()
        if os.path.isdir(wt) and wt != workspace:
            print(wt)
            exit(0)
except Exception:
    pass

print('')
" 2>/dev/null || echo "")
    if [ -n "$_AUTO_PROJECT_PATH" ]; then
        echo "[INFO] PROJECT_PATH 자동 인식: $_AUTO_PROJECT_PATH (task-timers.json worktree_path)"
        PROJECT_PATH="$_AUTO_PROJECT_PATH"
    fi
    unset _AUTO_PROJECT_PATH
fi

# task-2726 strict: PROJECT_PATH 미해결 시 scheduler/로컬 worktree 추가 해석
if [ "$FINISH_TASK_WORKTREE_STRICT" = "1" ]; then
    if [ -z "$PROJECT_PATH" ] || [ "$PROJECT_PATH" = "$WORKSPACE" ]; then
        _STRICT_WT=$(_resolve_task_worktree "$TASK_ID")
        if [ -n "$_STRICT_WT" ]; then
            echo "[INFO][STRICT] worktree 해석: $_STRICT_WT (task-2726 resolver)"
            PROJECT_PATH="$_STRICT_WT"
        fi
        unset _STRICT_WT
    fi
fi

# 0-pre. 보고서 세션 통계 idempotent 정리 (task-2348)
REPORT_FILE="$WORKSPACE/memory/reports/${TASK_ID}.md"
if [ -f "$REPORT_FILE" ]; then
    python3 - "$REPORT_FILE" <<'PYEOF'
import sys, re

path = sys.argv[1]
with open(path, encoding='utf-8') as f:
    content = f.read()

# "## 세션 통계" 섹션 모두 찾기 (각 섹션은 다음 ## 헤더 또는 파일 끝까지)
pattern = re.compile(r'^(## 세션 통계\b.*?)(?=^## |\Z)', re.MULTILINE | re.DOTALL)
matches = list(pattern.finditer(content))

if len(matches) < 2:
    # 0개 또는 1개면 정리 불필요
    sys.exit(0)

# 마지막 1개만 남기고 앞의 것들 모두 제거 (역순으로 삭제해 offset 오염 방지)
for m in reversed(matches[:-1]):
    content = content[:m.start()] + content[m.end():]

with open(path, 'w', encoding='utf-8') as f:
    f.write(content)
print(f'[INFO] 세션 통계 섹션 정리 완료 (제거 {len(matches)-1}개, 유지 1개)')
PYEOF
fi

# 0. 노하우 업데이트 검증 (디자인/마케팅 작업만)
TASK_FILE="$WORKSPACE/memory/tasks/${TASK_ID}.md"

# IS_DESIGN_TASK 판별 (task-2348: false positive 방어)
# 다음 조건 중 하나 이상 충족 시에만 디자인 작업으로 판정:
#  (1) affected_files 섹션에 이미지/에셋 파일 경로 패턴 존재
#  (2) 명시적 "## 디자인 작업: yes" 또는 YAML "design_task: true" 플래그
#  (3) 기존 키워드 매칭 AND task 제목(첫 줄 # 헤더)에도 디자인 키워드 포함
IS_DESIGN_TASK=0
if [ -f "$TASK_FILE" ]; then
    IS_DESIGN_TASK=$(python3 - "$TASK_FILE" <<'IS_DESIGN_PYEOF'
import sys, re

path = sys.argv[1]
try:
    with open(path, encoding='utf-8') as f:
        content = f.read()
except Exception:
    print(0)
    sys.exit(0)

# (1) affected_files 섹션에 이미지/에셋 경로 패턴
affected_section = re.search(r'## affected_files\b.*?(?=\n## |\Z)', content, re.DOTALL | re.IGNORECASE)
if affected_section:
    asset_pattern = re.compile(
        r'\.(png|jpg|jpeg|webp|gif|svg)\b|/assets/|/images/|/public/.*\.(png|jpg|svg)',
        re.IGNORECASE
    )
    if asset_pattern.search(affected_section.group()):
        print(1)
        sys.exit(0)

# (2) 명시적 디자인 작업 플래그
if re.search(r'^##\s*디자인 작업\s*:\s*yes', content, re.MULTILINE | re.IGNORECASE):
    print(1)
    sys.exit(0)
if re.search(r'^design_task\s*:\s*true', content, re.MULTILINE | re.IGNORECASE):
    print(1)
    sys.exit(0)

# (3) 기존 키워드 매칭 AND 제목(첫 # 헤더)에도 디자인 키워드 포함
keyword_pattern = re.compile(r'디자인|배너|이미지|banner|image|광고|마케팅|카피|copywriting', re.IGNORECASE)
if keyword_pattern.search(content):
    title_match = re.search(r'^#\s+(.+)', content, re.MULTILINE)
    if title_match:
        title = title_match.group(1)
        design_title_pattern = re.compile(r'디자인|배너|이미지|banner|image|광고|마케팅|카피', re.IGNORECASE)
        if design_title_pattern.search(title):
            print(1)
            sys.exit(0)

print(0)
IS_DESIGN_PYEOF
    2>/dev/null || echo "0")
fi

if [ -f "$TASK_FILE" ]; then
    # IS_DESIGN_TASK 기반 디자인/마케팅 작업 판별 (강화된 조건)
    if [ "${IS_DESIGN_TASK}" = "1" ]; then
        # task-timer에서 start_time 가져오기
        TASK_START=$(python3 -c "
import json
with open('$WORKSPACE/memory/task-timers.json') as f:
    data = json.load(f)
tasks = data.get('tasks', data)
t = tasks.get('$TASK_ID', {})
print(t.get('start_time', ''))
" 2>/dev/null || echo "")

        if [ -n "$TASK_START" ]; then
            # 노하우 파일들의 수정 시간이 작업 시작 이후인지 확인
            KNOWHOW_CHECK=$(python3 -c "
import os, sys
from datetime import datetime

start_str = '$TASK_START'
start_time = datetime.fromisoformat(start_str)

knowhow_files = [
    '$WORKSPACE/memory/specs/knowhow-design.md',
    '$WORKSPACE/memory/specs/design-qc-knowhow.md',
    '$WORKSPACE/memory/specs/knowhow-marketing.md',
]

updated = []
for f in knowhow_files:
    if os.path.exists(f):
        mtime = datetime.fromtimestamp(os.path.getmtime(f))
        if mtime > start_time:
            updated.append(os.path.basename(f))

if not updated:
    print('FAIL')
    print('노하우 파일이 작업 시작 이후 업데이트되지 않았습니다.', file=sys.stderr)
    print(f'작업 시작: {start_str}', file=sys.stderr)
    for f in knowhow_files:
        if os.path.exists(f):
            mtime = datetime.fromtimestamp(os.path.getmtime(f)).isoformat()
            print(f'  {os.path.basename(f)}: {mtime}', file=sys.stderr)
else:
    print('PASS')
" 2>/dev/null)

            if [ "$KNOWHOW_CHECK" = "FAIL" ]; then
                echo "[ERROR] ❌ 노하우 파일 업데이트 미확인. .done 생성 차단."
                echo "[ERROR] 디자인/마케팅 QC 후에는 반드시 노하우 파일을 업데이트해야 합니다."
                echo "[ERROR] 대상: knowhow-design.md, design-qc-knowhow.md, knowhow-marketing.md 중 최소 1개"
                echo "[HINT] --skip-knowhow-check 환경변수로 우회 가능: SKIP_KNOWHOW_CHECK=1 bash finish-task.sh $TASK_ID"
                if [ "${SKIP_KNOWHOW_CHECK:-0}" != "1" ]; then
                    exit 1
                fi
                echo "[WARN] SKIP_KNOWHOW_CHECK=1로 우회합니다."
            fi
        fi
    fi
fi

# 0b. QC 프로세스 검증 (디자인/이미지 작업만 — IS_DESIGN_TASK 사용)
if [ -f "$TASK_FILE" ]; then
    if [ "${IS_DESIGN_TASK}" = "1" ]; then
        if [ ! -f "$REPORT_FILE" ]; then
            echo "[WARN] ⚠️ 보고서 파일 없음: $REPORT_FILE — QC 프로세스 검증을 건너뜁니다."
        else
            # 검증 2: 로키 소환 검증
            if [ "${SKIP_LOKI_CHECK:-0}" != "1" ]; then
                LOKI_CHECK=$(python3 -c "
import re, sys
report_path = '$REPORT_FILE'
try:
    with open(report_path) as f:
        content = f.read()
    model_section = re.search(r'## 모델 사용 기록.*?(?=\n## |\Z)', content, re.DOTALL)
    if model_section:
        section_text = model_section.group()
        has_loki_opus = bool(re.search(r'로키|Loki', section_text)) and bool(re.search(r'opus', section_text))
        if not has_loki_opus:
            print('FAIL')
        else:
            print('PASS')
    else:
        print('FAIL')
except Exception as e:
    print('FAIL', file=sys.stderr)
    print('FAIL')
" 2>/dev/null || echo "FAIL")
                if [ "$LOKI_CHECK" = "FAIL" ]; then
                    echo "[ERROR] ❌ 디자인 QC에 로키(opus) 참여 기록 없음."
                    echo "[ERROR] 디자인 QC는 로키(opus) 단독 필수. 팀장 자체 평가는 규칙 위반."
                    exit 1
                fi
            else
                echo "[WARN] ⚠️ SKIP_LOKI_CHECK=1 — 로키 소환 체크를 우회합니다."
            fi

            # 검증 3: "팀장 시각 검수" 패턴 차단 (WARNING만)
            if grep -qiE "팀장 시각 검수|팀장 검수|팀장 판단|자체 평가" "$REPORT_FILE" 2>/dev/null; then
                echo "[WARN] ⚠️ \"팀장 시각 검수\" 패턴 감지. 디자인 QC는 로키(opus)만 수행 가능."
            fi
        fi
    fi
fi

# 1. QC 실행 (멱등성: .qc-done 이미 있으면 스킵)
QC_RESULT_FILE="$EVENTS_DIR/${TASK_ID}.qc-result"
QC_DONE_FILE="$EVENTS_DIR/${TASK_ID}.qc-done"

if [ -f "$QC_DONE_FILE" ]; then
    echo "[INFO] .qc-done 이미 존재 — QC 단계 스킵."
else
    # 팀별 qc_verify.py 경로 결정
    if [ -n "$TEAM_SHORT" ] && [ -f "$WORKSPACE/teams/${TEAM_SHORT}/qc/qc_verify.py" ]; then
        QC_SCRIPT="$WORKSPACE/teams/${TEAM_SHORT}/qc/qc_verify.py"
    else
        QC_SCRIPT="$WORKSPACE/teams/shared/qc_verify.py"
    fi

    echo "[INFO] QC 실행: $QC_SCRIPT --gate --task-id $TASK_ID"
    if [ -n "$TEAM_SHORT" ]; then
        python3 "$QC_SCRIPT" --gate --task-id "$TASK_ID" --team "$TEAM_SHORT" 2>&1 || true
    else
        python3 "$QC_SCRIPT" --gate --task-id "$TASK_ID" 2>&1 || true
    fi

    # QC 결과 확인
    if [ ! -f "$QC_RESULT_FILE" ]; then
        echo "[ERROR] QC 실행 후 .qc-result 파일이 생성되지 않았습니다: $QC_RESULT_FILE"
        exit 1
    fi

    QC_STATUS=$(python3 -c "
import json, sys
try:
    with open('$QC_RESULT_FILE') as f:
        data = json.load(f)
    print(data.get('qc_result', 'FAIL'))
except Exception:
    print('FAIL')
" 2>/dev/null || echo "FAIL")

    if [ "$QC_STATUS" = "FAIL" ]; then
        # QC FAIL 시 .failed 이벤트 생성
        FAILED_FILE="$EVENTS_DIR/${TASK_ID}.failed"
        echo '{"task_id":"'"$TASK_ID"'","team":"'"$TEAM_SHORT"'","fail_reason":"QC FAIL","timestamp":"'"$(date -Iseconds)"'"}' > "$FAILED_FILE"
        echo "[FAIL] QC FAIL — .failed 생성: $FAILED_FILE"
        _emit_failure_envelope QC_FAIL "$TASK_ID" 1 "qc_high_or_critical_fail" failure_envelope qc 2>/dev/null || true
        exit 1
    fi

    # QC 완료 표시
    echo '{"task_id":"'"$TASK_ID"'","qc_result":"'"$QC_STATUS"'","timestamp":"'"$(date -Iseconds)"'"}' > "$QC_DONE_FILE"
    echo "[INFO] QC $QC_STATUS — .qc-done 생성: $QC_DONE_FILE"
fi

# QC 결과 읽기 (후속 단계용)
QC_STATUS=$(python3 -c "
import json, sys
try:
    with open('$QC_RESULT_FILE') as f:
        data = json.load(f)
    print(data.get('qc_result', 'PASS'))
except Exception:
    print('PASS')
" 2>/dev/null || echo "PASS")

# 1.6. Scope Guard 검증 (task-2364, 머지 직전 hard wall)
# non-code task 판별 (Step 2.5의 GIT_GATE_SKIP보다 먼저 자체 계산)
_SCOPE_GATE_SKIP=0
if [ -f "$TASK_FILE" ]; then
    _SCOPE_GATE_SKIP=$(python3 -c "
import re, sys
try:
    with open('$TASK_FILE', encoding='utf-8') as f:
        content = f.read()
    m = re.search(r'## 레벨\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL)
    if m:
        section = m.group(1)
        if re.search(r'코드 수정 없음|문서 업데이트만|문서만|리서치만', section):
            print('1')
        else:
            print('0')
    else:
        print('0')
except Exception:
    print('0')
" 2>/dev/null || echo "0")
fi

SCOPE_GUARD_DONE_FILE="$EVENTS_DIR/${TASK_ID}.scope-guard-done"
if [ -f "$SCOPE_GUARD_DONE_FILE" ]; then
    echo "[INFO] .scope-guard-done 이미 존재 — scope 검증 스킵."
elif [ "$_SCOPE_GATE_SKIP" -eq 1 ] 2>/dev/null; then
    echo "[SCOPE-GUARD] non-code task — scope 검증 스킵."
else
    # PROJECT_PATH 미지정 시 workspace 자체 사용 (시스템 task 보호 — Codex 지적 반영)
    # task-2726: strict mode 시 shared main silent fallback 제거 (fail-closed)
    if [ "$FINISH_TASK_WORKTREE_STRICT" = "1" ]; then
        if [ -n "$PROJECT_PATH" ] && [ "$PROJECT_PATH" != "$WORKSPACE" ]; then
            SCOPE_PROJ_DIR="$PROJECT_PATH"
        elif _task_expects_worktree "$TASK_ID"; then
            _emit_worktree_unresolved "$TASK_ID" "scope-gate: PROJECT_PATH unresolved for worktree task"
            exit 1
        else
            SCOPE_PROJ_DIR="$WORKSPACE"
        fi
    else
        SCOPE_PROJ_DIR="${PROJECT_PATH:-$WORKSPACE}"
    fi
    # workspace 자체가 git이 아닌 경우: STRICT → fail-closed(exit 1), non-strict → 레거시 SKIP 보존
    # task-2726+4 HIGH-1: STRICT 에서 non-git = scope 검증 불가 → fail-OPEN(스킵) 금지, 미검증 변경 통과 차단.
    if ! git -C "$SCOPE_PROJ_DIR" rev-parse --git-dir >/dev/null 2>&1; then
        if [ "$FINISH_TASK_WORKTREE_STRICT" = "1" ]; then
            _emit_worktree_unresolved "$TASK_ID" "scope-gate: $SCOPE_PROJ_DIR not a git repo (STRICT) — 검증 불가 fail-closed"
            echo "[ERROR][STRICT] scope-gate: $SCOPE_PROJ_DIR not a git repo — scope 검증 불가, 미검증 변경 통과 차단(fail-closed). worktree task 는 PROJECT_PATH 를 valid git worktree 로 지정하거나 FINISH_TASK_WORKTREE_STRICT 를 비활성화하세요." >&2
            exit 1
        fi
        echo "[SCOPE-GUARD] $SCOPE_PROJ_DIR not a git repo — 검증 스킵."
    else
        # diff 계산: worktree 브랜치 기준 main..HEAD
        SCOPE_DIFF_FILE="$EVENTS_DIR/${TASK_ID}.scope-diff.txt"
        # main 브랜치 이름 추정 (main 또는 master)
        MAIN_BRANCH=$(git -C "$SCOPE_PROJ_DIR" rev-parse --verify main >/dev/null 2>&1 && echo main || echo master)
        if [ "$FINISH_TASK_WORKTREE_STRICT" = "1" ]; then
            # task-2726+2: scope-base = merge-base(origin/${MAIN_BRANCH}, HEAD), HEAD~1/로컬 main fallback 폐기 (origin/main 하드코딩 제거)
            SCOPE_BASE=$(git -C "$SCOPE_PROJ_DIR" merge-base "origin/${MAIN_BRANCH}" HEAD 2>/dev/null || echo "")
            if [ -z "$SCOPE_BASE" ] && [ -f "$EVENTS_DIR/${TASK_ID}.worktree-base.json" ]; then
                SCOPE_BASE=$(python3 -c "import json;print(json.load(open('$EVENTS_DIR/${TASK_ID}.worktree-base.json')).get('base_sha',''))" 2>/dev/null || echo "")
            fi
            if [ -n "$SCOPE_BASE" ]; then
                git -C "$SCOPE_PROJ_DIR" diff --name-only "${SCOPE_BASE}..HEAD" > "$SCOPE_DIFF_FILE" 2>/dev/null || true
            else
                # task-2726+3 (회장 A안): STRICT mode + SCOPE_BASE 미감지(merge-base origin/${MAIN_BRANCH}..HEAD
                #   + worktree-base.json fallback 모두 실패) → 빈 diff 로 scope 검증 스킵(fail-OPEN) 금지.
                #   명시적 fail-closed: 미검증 변경 통과 차단.
                _emit_worktree_unresolved "$TASK_ID" "scope-gate: SCOPE_BASE 미감지 (merge-base origin/${MAIN_BRANCH}..HEAD + worktree-base.json fallback empty) — fail-closed"
                echo "[ERROR][STRICT] scope-gate: SCOPE_BASE 미감지 — 미검증 변경 통과 차단 (fail-closed)" >&2
                exit 1
            fi
        else
            git -C "$SCOPE_PROJ_DIR" diff --name-only "${MAIN_BRANCH}..HEAD" > "$SCOPE_DIFF_FILE" 2>/dev/null \
                || git -C "$SCOPE_PROJ_DIR" diff --name-only HEAD~1 > "$SCOPE_DIFF_FILE" 2>/dev/null \
                || true
        fi

        if [ ! -s "$SCOPE_DIFF_FILE" ]; then
            # task-2726+4 fail-open family 종결: STRICT 에서 diff 비어있음 = worktree 변경 미검출(base/branch 해석 오류 가능)
            #   → fail-OPEN(스킵) 금지, fail-closed(exit 1). non-strict 는 레거시 SKIP 보존.
            if [ "$FINISH_TASK_WORKTREE_STRICT" = "1" ]; then
                _emit_worktree_unresolved "$TASK_ID" "scope-gate: diff empty(SCOPE_BASE..HEAD 변경 0건, STRICT) — 검증 대상 부재 fail-closed"
                echo "[ERROR][STRICT] scope-gate: diff 비어있음(SCOPE_BASE..HEAD 변경 0건) — worktree task 변경 미검출, 미검증 통과 차단(fail-closed). base SHA/branch 해석을 확인하세요." >&2
                exit 1
            fi
            echo "[SCOPE-GUARD] diff 비어있음 — 검증 스킵."
        else
            echo "[SCOPE-GUARD] task-scope-guard.sh 호출: $SCOPE_DIFF_FILE"
            set +e
            bash "$WORKSPACE/scripts/task-scope-guard.sh" "$TASK_ID" "$SCOPE_DIFF_FILE"
            SCOPE_EXIT=$?
            set -e
            if [ "$SCOPE_EXIT" -ne 0 ]; then
                echo "[SCOPE-GUARD] FAIL — 머지 차단 + .escalate 생성" >&2
                ESCALATE_FILE="$EVENTS_DIR/${TASK_ID}.escalate"
                echo '{"task_id":"'"$TASK_ID"'","reason":"scope_guard_violation","timestamp":"'"$(date -Iseconds)"'"}' > "$ESCALATE_FILE"
                # task-2712 §5.1 L451 scope-guard FAIL → failure envelope (task-2711 사례 박제)
                SCOPE_VIO_CNT=$(wc -l < "$SCOPE_DIFF_FILE" 2>/dev/null | tr -d ' ' || echo 0)
                _emit_failure_envelope SCOPE_GUARD_FAIL "$TASK_ID" "$SCOPE_EXIT" "scope_violation_count_${SCOPE_VIO_CNT}" failure_envelope scope_guard 2>/dev/null || true
                exit 1
            fi
            echo '{"task_id":"'"$TASK_ID"'","timestamp":"'"$(date -Iseconds)"'"}' > "$SCOPE_GUARD_DONE_FILE"
            echo "[SCOPE-GUARD] PASS — .scope-guard-done 생성"
        fi
    fi
fi

# ── task-2729+3: merge_policy 해석 (fail-CLOSED) — FINISH_TASK_STRICT_MODE_MERGE_POLICY_GAP 보정 ──
# >>> task-2729+3 FINALIZE_ONLY_RESOLVER_GATE_BEGIN
# task md allowed_resources 의 merge_policy 를 resolver(순수함수)로 해석한다.
#   - merge_policy=none                    → finalize-only 승격(merge block 스킵).
#   - non-empty/non-none (tiered/auto 등)  → 기존 동작 보존(merge block 정상 진입).
# fail-CLOSED: resolver/task-md 부재·실행실패·빈값(미지정) → merge 진행 금지(안전기본값=skip).
#   fail-OPEN 금지: `2>/dev/null` 로 stderr 를 삼키거나 `|| echo ""` 로 실패를 무시해 merge 를
#   진행시키는 패턴을 쓰지 않는다(resolver stderr 는 로그로 노출).
# 이미 _FINALIZE_ONLY=1 (env FINALIZE_ONLY=1 / 보조호환 --finalize-only) 이면 resolver 를 건너뛴다.
if [ "${_FINALIZE_ONLY:-0}" != "1" ]; then
    _MP_RESOLVER="$WORKSPACE/scripts/harness/v36/merge_policy_resolver.py"
    _MP_TASK_MD="$WORKSPACE/memory/tasks/${TASK_ID}.md"
    _MERGE_POLICY=""
    _MP_OK=0
    if [ -f "$_MP_RESOLVER" ] && [ -f "$_MP_TASK_MD" ]; then
        # 2>/dev/null 미사용 → resolver stderr(에러)는 로그로 노출. 실패 시 _MP_OK=0 유지(fail-CLOSED).
        if _MERGE_POLICY=$(python3 "$_MP_RESOLVER" --task-md "$_MP_TASK_MD"); then
            _MP_OK=1
        else
            echo "[FINALIZE-ONLY][fail-closed] merge_policy resolver 실행 실패 — merge block 스킵(안전기본값)" >&2
        fi
    else
        echo "[FINALIZE-ONLY][fail-closed] resolver/task-md 부재 (resolver=$_MP_RESOLVER, task-md=$_MP_TASK_MD) — merge block 스킵(안전기본값)" >&2
    fi
    if [ "$_MP_OK" != "1" ]; then
        _FINALIZE_ONLY=1
    elif [ -z "$_MERGE_POLICY" ]; then
        echo "[FINALIZE-ONLY][fail-closed] merge_policy 미지정/빈값 — merge block 스킵(안전기본값)" >&2
        _FINALIZE_ONLY=1
    elif [ "$_MERGE_POLICY" = "none" ]; then
        echo "[FINALIZE-ONLY] merge_policy=none honored (task md allowed_resources) — merge block 스킵 결정"
        _FINALIZE_ONLY=1
    else
        echo "[INFO] merge_policy=$_MERGE_POLICY — merge block 정상 진입(기존 동작 보존)"
    fi
fi
# <<< task-2729+3 FINALIZE_ONLY_RESOLVER_GATE_END

# 2. 머지 (멱등성: .merge-done 이미 있으면 스킵, project_path 없으면 스킵)
# >>> task-2729+3 FINALIZE_ONLY_MERGE_BLOCK_BEGIN
MERGE_DONE_FILE="$EVENTS_DIR/${TASK_ID}.merge-done"

if [ "${_FINALIZE_ONLY:-0}" = "1" ]; then
    # merge_policy=none / --finalize-only / FINALIZE_ONLY=1 / fail-CLOSED:
    # worktree_manager finish · PR gate · owner_gemini trigger 전부 미실행(side-effect 0). .merge-done 미생성.
    FINALIZE_ONLY_MARKER="$EVENTS_DIR/${TASK_ID}.finalize-only"
    echo "[FINALIZE-ONLY] merge block 스킵 — worktree_manager finish/PR gate/Gemini trigger 미실행 (PROJECT_PATH=${PROJECT_PATH:-<none>})"
    echo '{"task_id":"'"$TASK_ID"'","reason":"merge_policy=none|finalize-only|fail-closed","merge_executed":false,"timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' > "$FINALIZE_ONLY_MARKER"
    echo "[FINALIZE-ONLY] .finalize-only 마커 생성 (.merge-done 미생성): $FINALIZE_ONLY_MARKER"
elif [ -f "$MERGE_DONE_FILE" ]; then
    echo "[INFO] .merge-done 이미 존재 — 머지 단계 스킵."
elif [ -z "$PROJECT_PATH" ]; then
    echo "[INFO] PROJECT_PATH 미지정 — 머지 단계 스킵 (시스템 작업)."
else
    # ── Lock-in 1 First-line: cancelled + guard.sh가 else 블록 첫 statement ──
    if [[ -f "$EVENTS_DIR/${TASK_ID}.cancelled" ]]; then
        echo "[CANCELLED] merge step blocked — task $TASK_ID cancelled (.cancelled 마커 존재)" >&2
        exit 1
    fi
    # task-2472: state file missing 시 merge 차단
    STATE_FILE="$WORKSPACE/.tasks/state/${TASK_ID}.json"
    if [ ! -f "$STATE_FILE" ]; then
        echo "[ERROR] state file missing: $STATE_FILE — merge 차단 (task-2472)" >&2
        python3 "$WORKSPACE/scripts/escalation_marker.py" emit \
            --task-id "$TASK_ID" \
            --kind escalated \
            --reason "state file missing" \
            --source "finish-task.sh" \
            --blocking "state_file_missing" \
            --evidence "$STATE_FILE" 2>/dev/null || true
        exit 1
    fi

    # task-2467: BLOCKED/ESCALATED 시 .done 차단
    # task-2472: raw `: >` emit 제거 → escalation_marker.py JSON payload 발행으로 교체
    # Gemini 리뷰 medium: || echo '{}' fallback 제거 (taskctl 실패를 무시하면 corruption 시 merge 진행 위험).
    # taskctl status가 비-zero exit하면 fail-closed.
    if ! TASKCTL_STATUS_JSON=$(python3 "$WORKSPACE/scripts/taskctl.py" status "$TASK_ID" --machine 2>&1); then
        echo "[ERROR] taskctl status 실패 — merge 차단 ($TASK_ID): $TASKCTL_STATUS_JSON" >&2
        exit 1
    fi
    CURRENT_STATE=$(echo "$TASKCTL_STATUS_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('current_state','') if isinstance(d,dict) else '')" 2>/dev/null || echo "")
    case "$CURRENT_STATE" in
        BLOCKED)
            python3 "$WORKSPACE/scripts/escalation_marker.py" emit \
                --task-id "$TASK_ID" \
                --kind blocked \
                --reason "taskctl state=BLOCKED at finish-task.sh" \
                --source "finish-task.sh" \
                --blocking "taskctl_state_blocked" \
                --evidence "$EVENTS_DIR/${TASK_ID}.qc-result" || {
                echo "[ERROR] escalation_marker emit failed (BLOCKED) — fail-closed" >&2
                exit 1
            }
            echo "[BLOCKED] taskctl state=BLOCKED. .done.blocked JSON payload 발행."
            python3 "$WORKSPACE/memory/task-timer.py" end "$TASK_ID" --qc-result BLOCKED 2>&1 || true
            _timer_ended=1
            exit 0
            ;;
        ESCALATED)
            python3 "$WORKSPACE/scripts/escalation_marker.py" emit \
                --task-id "$TASK_ID" \
                --kind escalated \
                --reason "taskctl state=ESCALATED at finish-task.sh" \
                --source "finish-task.sh" \
                --blocking "taskctl_state_escalated" \
                --evidence "$EVENTS_DIR/${TASK_ID}.qc-result" || {
                echo "[ERROR] escalation_marker emit failed (ESCALATED) — fail-closed" >&2
                exit 1
            }
            echo "[ESCALATED] taskctl state=ESCALATED. .done.escalated JSON payload 발행."
            python3 "$WORKSPACE/memory/task-timer.py" end "$TASK_ID" --qc-result ESCALATED 2>&1 || true
            _timer_ended=1
            exit 0
            ;;
    esac
    if ! bash "$WORKSPACE/scripts/guard.sh" pre-push "$TASK_ID"; then
        echo "[GUARD-FAIL] pre-push guard 실패 — merge 차단 ($TASK_ID)" >&2
        exit 1
    fi
    if ! bash "$WORKSPACE/scripts/guard.sh" qc-check "$TASK_ID"; then
        echo "[GUARD-FAIL] qc-check guard 실패 — merge 차단 ($TASK_ID)" >&2
        exit 1
    fi
    echo "[INFO] 머지 실행: worktree_manager.py finish $PROJECT_PATH $TASK_ID $TEAM_SHORT --action auto"
    python3 "$WORKSPACE/scripts/worktree_manager.py" finish "$PROJECT_PATH" "$TASK_ID" "$TEAM_SHORT" --action auto 2>&1 || {
        echo "[WARN] worktree_manager.py finish 실패 — 계속 진행."
    }
    echo '{"task_id":"'"$TASK_ID"'","project_path":"'"$PROJECT_PATH"'","timestamp":"'"$(date -Iseconds)"'"}' > "$MERGE_DONE_FILE"
    echo "[INFO] 머지 완료 — .merge-done 생성: $MERGE_DONE_FILE"
    # task-2367 P1: post-merge health probe (5분 후 background 실행)
    if [ -f "$WORKSPACE/scripts/post_merge_probe.py" ]; then
        MERGE_SHA="${MERGE_SHA:-$(cd "${PROJECT_PATH:-$WORKSPACE}" 2>/dev/null && git rev-parse HEAD 2>/dev/null || echo "")}"
        if [ -n "$MERGE_SHA" ]; then
            # 멱등 마크가 있으면 스킵
            PROBE_MARK="$EVENTS_DIR/${TASK_ID}.probe-done"
            if [ ! -f "$PROBE_MARK" ]; then
                echo "[POST-PROBE] 5분 후 health probe 예약: ${TASK_ID} sha=${MERGE_SHA:0:8}"
                (
                    python3 "$WORKSPACE/scripts/post_merge_probe.py" \
                        --task-id "$TASK_ID" \
                        --merge-sha "$MERGE_SHA" \
                        --project-path "${PROJECT_PATH:-$WORKSPACE}" \
                        --delay 300 \
                        >> "$WORKSPACE/logs/post-merge-probe.log" 2>&1 &
                ) </dev/null
                echo "[POST-PROBE] background pid 분리됨"
            fi
        fi
    fi
fi
# <<< task-2729+3 FINALIZE_ONLY_MERGE_BLOCK_END

# 2.3. PR 머지 검증 (worktree PR 생성한 경우만)
# >>> task-2729+3 FINALIZE_ONLY_PR_GATE_BEGIN
# finalize-only(merge_policy=none / --finalize-only / FINALIZE_ONLY=1 / fail-CLOSED)는 merge 를
# 의도적으로 미수행하므로 같은 브랜치의 PR 이 open 인 것이 정상 상태다. 이 PR-GATE 가 그대로
# 돌면 항상 BLOCKED(exit 1) → merge 미수행 상태에서 PR 검증 실패가 발생한다. finalize-only 면 스킵.
if [ "${_FINALIZE_ONLY:-0}" = "1" ]; then
    echo "[FINALIZE-ONLY] 2.3 PR 머지 검증 스킵 — merge 미수행이 정상(PR open 허용), PR-GATE BLOCKED 0."
elif [ -n "$PROJECT_PATH" ] && [ -d "$PROJECT_PATH/.git" ]; then
    BRANCH_NAME="task/${TASK_ID}-${TEAM_SHORT}"
    OPEN_PR=$(gh pr list --state open --head "$BRANCH_NAME" --json number --jq length 2>/dev/null || echo "0")
    if [ "$OPEN_PR" -gt 0 ]; then
        echo "[PR-GATE] BLOCKED: PR이 아직 머지되지 않았습니다 (branch: $BRANCH_NAME)"
        exit 1
    fi
fi
# <<< task-2729+3 FINALIZE_ONLY_PR_GATE_END

# 2.5. Git 커밋 검증 게이트 (task-2031)
# non-code task 판별: 코드 수정 없는 작업은 git 게이트 SKIP
GIT_GATE_SKIP=0
if [ -f "$TASK_FILE" ]; then
    # ## 레벨 섹션 내에서만 non-code 키워드를 검사 (본문의 설명 텍스트 오탐 방지)
    GIT_GATE_SKIP=$(python3 -c "
import re, sys
try:
    with open('$TASK_FILE', encoding='utf-8') as f:
        content = f.read()
    # ## 레벨 섹션 추출
    m = re.search(r'## 레벨\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL)
    if m:
        section = m.group(1)
        if re.search(r'코드 수정 없음|문서 업데이트만|문서만|리서치만', section):
            print('1')
        else:
            print('0')
    else:
        print('0')
except Exception:
    print('0')
" 2>/dev/null || echo "0")
    if [ "$GIT_GATE_SKIP" -eq 1 ]; then
        echo "[GIT-GATE] non-code task 감지 — git 검증 SKIP."
    fi
fi

# 변수 기본값 초기화 (Git 게이트 skip 시에도 후속 게이트가 참조 가능하도록)
# task-2726: strict mode PROJ_DIR 해석 (shared main fallback 제거)
if [ "$FINISH_TASK_WORKTREE_STRICT" = "1" ] && [ "${GIT_GATE_SKIP:-0}" -eq 0 ]; then
    if [ -n "$PROJECT_PATH" ] && [ "$PROJECT_PATH" != "$WORKSPACE" ]; then
        PROJ_DIR="$PROJECT_PATH"
    elif _task_expects_worktree "$TASK_ID"; then
        _emit_worktree_unresolved "$TASK_ID" "git-gate: PROJECT_PATH unresolved for worktree task"
        exit 1
    else
        PROJ_DIR="$WORKSPACE"
    fi
else
    PROJ_DIR="${PROJECT_PATH:-$WORKSPACE}"
fi
COMMIT_COUNT=0

if [ "$GIT_GATE_SKIP" -eq 0 ]; then
    # 작업 디렉토리 기반 프로젝트 루트 감지
    if [ "$FINISH_TASK_WORKTREE_STRICT" = "1" ]; then
        if [ -n "$PROJ_DIR" ] && [ "$PROJ_DIR" != "$WORKSPACE" ]; then
            WORK_DIR="$PROJ_DIR"
        elif _task_expects_worktree "$TASK_ID"; then
            _emit_worktree_unresolved "$TASK_ID" "git-gate work-dir: PROJ_DIR unresolved for worktree task"
            exit 1
        else
            WORK_DIR="$PROJ_DIR"
        fi
    else
        WORK_DIR="${PROJECT_PATH:-$WORKSPACE}"
    fi
    PROJ_DIR=$(git -C "$WORK_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$WORK_DIR")

    # 1) task ID 커밋 최소 1건
    COMMIT_COUNT=$(git -C "$PROJ_DIR" log --oneline --all --grep="$TASK_ID" 2>/dev/null | wc -l)
    if [ "$COMMIT_COUNT" -eq 0 ]; then
        echo "[GIT-GATE] BLOCKED: $TASK_ID 커밋 0건. git commit 후 재실행." >&2
        exit 1
    fi
    echo "[GIT-GATE] 커밋 $COMMIT_COUNT건 확인."

    # 2) uncommitted 변경 없음 (시스템 자동 파일 제외)
    REAL_DIFF=$(git -C "$PROJ_DIR" diff --name-only 2>/dev/null | { grep -v -E '(memory/heartbeats/|memory/events/|memory/daily/|memory/logs/|memory/reports/|memory/tasks/|logs/|whisper/|bot-activity\.json|token-ledger\.json|memory/pipeline-status\.json|memory/preview-state\.json|memory/merge-log\.json|memory/bot_settings_sync\.json|memory/memory-check-log\.json|dashboard/data/refine-|dashboard/data/medium-comments-log\.json|\.heartbeat|memory/\.task-counter|memory/task-timers\.json|memory/canary-status\.json|scripts/gemini_rate_tracker\.json|tests/coverage-report\.txt|config/constants\.json)' || true; } | wc -l)
    REAL_CACHED=$(git -C "$PROJ_DIR" diff --cached --name-only 2>/dev/null | { grep -v -E '(memory/heartbeats/|memory/events/|memory/daily/|memory/logs/|memory/reports/|memory/tasks/|logs/|whisper/|bot-activity\.json|token-ledger\.json|memory/pipeline-status\.json|memory/preview-state\.json|memory/merge-log\.json|memory/bot_settings_sync\.json|memory/memory-check-log\.json|dashboard/data/refine-|dashboard/data/medium-comments-log\.json|\.heartbeat|memory/\.task-counter|memory/task-timers\.json|memory/canary-status\.json|scripts/gemini_rate_tracker\.json|tests/coverage-report\.txt|config/constants\.json)' || true; } | wc -l)
    if [ "$REAL_DIFF" -gt 0 ] || [ "$REAL_CACHED" -gt 0 ]; then
        echo "[GIT-GATE] BLOCKED: uncommitted 변경 존재 (${REAL_DIFF} unstaged, ${REAL_CACHED} staged)." >&2

        # task-2700 B-1: dirty 분리 진단 + 마커 (요구 8,10,11) — exit 1 직전 추가
        _DIRTY_CLASSIFICATION="UNKNOWN"
        _DIRTY_UNRELATED_N=0
        {
            # dirty 파일 목록 수집 (필터 적용된 목록)
            _DIRTY_PATHS_UNSTAGED=$(git -C "$PROJ_DIR" diff --name-only 2>/dev/null | grep -v -E '(memory/heartbeats/|memory/events/|memory/daily/|memory/logs/|memory/reports/|memory/tasks/|logs/|whisper/|bot-activity\.json|token-ledger\.json|memory/pipeline-status\.json|memory/preview-state\.json|memory/merge-log\.json|memory/bot_settings_sync\.json|memory/memory-check-log\.json|dashboard/data/refine-|dashboard/data/medium-comments-log\.json|\.heartbeat|memory/\.task-counter|memory/task-timers\.json|memory/canary-status\.json|scripts/gemini_rate_tracker\.json|tests/coverage-report\.txt|config/constants\.json)' 2>/dev/null || true)
            _DIRTY_PATHS_STAGED=$(git -C "$PROJ_DIR" diff --cached --name-only 2>/dev/null | grep -v -E '(memory/heartbeats/|memory/events/|memory/daily/|memory/logs/|memory/reports/|memory/tasks/|logs/|whisper/|bot-activity\.json|token-ledger\.json|memory/pipeline-status\.json|memory/preview-state\.json|memory/merge-log\.json|memory/bot_settings_sync\.json|memory/memory-check-log\.json|dashboard/data/refine-|dashboard/data/medium-comments-log\.json|\.heartbeat|memory/\.task-counter|memory/task-timers\.json|memory/canary-status\.json|scripts/gemini_rate_tracker\.json|tests/coverage-report\.txt|config/constants\.json)' 2>/dev/null || true)
            _ALL_DIRTY_PATHS=$(printf '%s\n%s' "$_DIRTY_PATHS_UNSTAGED" "$_DIRTY_PATHS_STAGED" | sort -u | grep -v '^$' || true)

            # dirty_registry.py 로 owner 분류
            _CLASSIFY_JSON=$(python3 - "$PROJ_DIR" "$TASK_ID" "$WORKSPACE/memory/capabilities" <<'PYEOF_DIRTY' 2>/dev/null
import sys, json
repo_root, task_id, cap_dir = sys.argv[1], sys.argv[2], sys.argv[3]

# stdin에서 dirty paths 읽기 불가 → 여기선 args 방식 사용 불가, 직접 git 호출
import subprocess, re
EXCLUDE_RE = re.compile(r'(memory/heartbeats/|memory/events/|memory/daily/|memory/logs/|memory/reports/|memory/tasks/|logs/|whisper/|bot-activity\.json|token-ledger\.json|memory/pipeline-status\.json|memory/preview-state\.json|memory/merge-log\.json|memory/bot_settings_sync\.json|memory/memory-check-log\.json|dashboard/data/refine-|dashboard/data/medium-comments-log\.json|\.heartbeat|memory/\.task-counter|memory/task-timers\.json|memory/canary-status\.json|scripts/gemini_rate_tracker\.json|tests/coverage-report\.txt|config/constants\.json)')

def get_filtered(cmd):
    r = subprocess.run(cmd, cwd=repo_root, capture_output=True, text=True)
    return [p for p in r.stdout.splitlines() if p and not EXCLUDE_RE.search(p)]

dirty_paths = list(set(get_filtered(['git','diff','--name-only']) + get_filtered(['git','diff','--cached','--name-only'])))

sys.path.insert(0, repo_root)
try:
    from utils.dirty_registry import load_expected_files, classify_blocker
    expected = load_expected_files(cap_dir, task_id)
    result = classify_blocker(expected, dirty_paths)
    print(json.dumps(result))
except Exception as e:
    print(json.dumps({"classification": "UNKNOWN", "own_dirty": [], "unrelated_dirty": [], "error": str(e)}))
PYEOF_DIRTY
            ) || _CLASSIFY_JSON='{"classification":"UNKNOWN","own_dirty":[],"unrelated_dirty":[]}'

            _DIRTY_CLASSIFICATION=$(echo "$_CLASSIFY_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('classification','UNKNOWN'))" 2>/dev/null || echo "UNKNOWN")
            _DIRTY_UNRELATED_N=$(echo "$_CLASSIFY_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('unrelated_dirty',[])))" 2>/dev/null || echo "0")

            if [ "$_DIRTY_CLASSIFICATION" = "EXTERNAL_DIRTY_BLOCKER" ]; then
                echo "[GIT-GATE] EXTERNAL_DIRTY_BLOCKER: 무관 dirty ${_DIRTY_UNRELATED_N}건 — task 책임 아님(환경 블로커). origin sync 또는 dirty 정리 필요." >&2
                # 마커 기록
                python3 - "$EVENTS_DIR" "$TASK_ID" "$_CLASSIFY_JSON" <<'PYEOF_MARKER' 2>/dev/null || true
import sys, json, time
from pathlib import Path
events_dir, task_id, classify_json_str = sys.argv[1], sys.argv[2], sys.argv[3]
try:
    classify_data = json.loads(classify_json_str)
    marker = {
        "task_id": task_id,
        "classification": classify_data.get("classification"),
        "unrelated_dirty": classify_data.get("unrelated_dirty", []),
        "ts": time.time(),
    }
    Path(events_dir).mkdir(parents=True, exist_ok=True)
    (Path(events_dir) / f"{task_id}.external-dirty-blocker.json").write_text(json.dumps(marker, ensure_ascii=False, indent=2))
except Exception as e:
    print(f"[WARN] external-dirty-blocker marker 기록 실패: {e}", file=sys.stderr)
PYEOF_MARKER
            elif [ "$_DIRTY_CLASSIFICATION" = "OWN_DIRTY_FAIL" ]; then
                echo "[GIT-GATE] BLOCKED: own expected_files dirty — task 책임." >&2
            fi

            # callback 원인 분류 마커 기록 (요구 9)
            python3 - "$EVENTS_DIR" "$TASK_ID" "$_DIRTY_CLASSIFICATION" <<'PYEOF_CALLBACK' 2>/dev/null || true
import sys, json, time
from pathlib import Path
events_dir, task_id, blocker_cls = sys.argv[1], sys.argv[2], sys.argv[3]
try:
    import sys as _sys
    _sys.path.insert(0, str(Path(events_dir).parent.parent))
    from utils.callback_cause_classifier import classify_callback_missing
    result = classify_callback_missing(done_exists=False, git_gate_blocked=True, blocker_classification=blocker_cls)
    result["ts"] = time.time()
    result["task_id"] = task_id
    Path(events_dir).mkdir(parents=True, exist_ok=True)
    (Path(events_dir) / f"{task_id}.callback-cause.json").write_text(json.dumps(result, ensure_ascii=False, indent=2))
except Exception as e:
    print(f"[WARN] callback-cause marker 기록 실패: {e}", file=sys.stderr)
PYEOF_CALLBACK
        } || true
        # 차단 유지 (fail-closed): 분류 결과에 관계없이 exit 1
        exit 1
    fi
    echo "[GIT-GATE] uncommitted 변경 없음 확인 (시스템 자동 파일 제외)."

    # 3) 빈 커밋 방어
    LAST_HASH=$(git -C "$PROJ_DIR" log --all --grep="$TASK_ID" --format="%H" -1 2>/dev/null)
    if [ -n "$LAST_HASH" ]; then
        DIFF_FILES=$(git -C "$PROJ_DIR" diff --name-only "${LAST_HASH}^..${LAST_HASH}" 2>/dev/null | wc -l)
        if [ "$DIFF_FILES" -eq 0 ]; then
            echo "[GIT-GATE] BLOCKED: 빈 커밋(변경 파일 0건)." >&2
            exit 1
        fi
        echo "[GIT-GATE] 마지막 커밋 변경 파일 ${DIFF_FILES}건 확인."
    fi

    echo "[GIT-GATE] PASS — git 검증 통과."

    # task-2700 B-2: merge-base 검증 (요구 5) — GIT-GATE PASS 직후 추가
    # PROJECT_PATH 지정 worktree 작업일 때만 실행 (시스템 task 영향 없음)
    if [ -n "$PROJECT_PATH" ]; then
        {
            # task-2726+2: MAIN_BRANCH 동적 감지 (main/master) → origin/main 하드코딩 제거, fail-closed 유지
            MAIN_BRANCH=$(git -C "$PROJ_DIR" rev-parse --verify main >/dev/null 2>&1 && echo main || echo master)
            _ORIGIN_SHA=$(git -C "$PROJ_DIR" rev-parse "origin/${MAIN_BRANCH}" 2>/dev/null) || true
            if [ -n "$_ORIGIN_SHA" ]; then
                _MERGE_BASE=$(git -C "$PROJ_DIR" merge-base HEAD "origin/${MAIN_BRANCH}" 2>/dev/null) || true
                if [ -n "$_MERGE_BASE" ]; then
                    if [ "$_MERGE_BASE" = "$_ORIGIN_SHA" ]; then
                        echo "[MERGE-BASE] PASS — base가 origin/${MAIN_BRANCH} 최신과 일치 (${_ORIGIN_SHA:0:8})"
                    else
                        echo "[MERGE-BASE] WARN: stale base 감지 (merge-base ${_MERGE_BASE:0:8} ≠ origin/${MAIN_BRANCH} ${_ORIGIN_SHA:0:8}) — PR #158류 stale base 위험" >&2
                        # stale-base 마커 기록
                        python3 - "$EVENTS_DIR" "$TASK_ID" "$_MERGE_BASE" "$_ORIGIN_SHA" <<'PYEOF_STALE' 2>/dev/null || true
import sys, json, time
from pathlib import Path
events_dir, task_id, merge_base, origin_sha = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
try:
    marker = {
        "task_id": task_id,
        "merge_base": merge_base,
        "origin_main_sha": origin_sha,
        "stale": True,
        "ts": time.time(),
    }
    Path(events_dir).mkdir(parents=True, exist_ok=True)
    (Path(events_dir) / f"{task_id}.stale-base-warn.json").write_text(json.dumps(marker, ensure_ascii=False, indent=2))
except Exception as e:
    print(f"[WARN] stale-base-warn marker 기록 실패: {e}", file=sys.stderr)
PYEOF_STALE
                    fi
                fi
            fi
        } || true
    fi
fi

# 2.6. Impact Scanner Gate
IMPACT_RESULT_VAL="PASS"
IMPACT_ENABLED=$(python3 -c "from utils.gate_config_loader import is_gate_enabled; print(is_gate_enabled('impact_scanner'))" 2>/dev/null || echo "false")
if [ "$IMPACT_ENABLED" = "True" ]; then
    IMPACT_MODE=$(python3 -c "from utils.gate_config_loader import get_gate_mode; print(get_gate_mode('impact_scanner'))" 2>/dev/null || echo "warn")
    set +e
    IMPACT_RAW=$(python3 scripts/impact_scanner.py --project-root "$PROJ_DIR" --task-id "$TASK_ID" 2>/dev/null | tail -1)
    IMPACT_EXIT=$?
    set -e
    IMPACT_GATE=$(echo "$IMPACT_RAW" | python3 -c "import sys,json; print(json.load(sys.stdin).get('gate_result','PASS'))" 2>/dev/null || echo "PASS")
    if [ $IMPACT_EXIT -eq 2 ] || [ "$IMPACT_GATE" = "BLOCK" ]; then
        IMPACT_RESULT_VAL="BLOCK"
    elif [ $IMPACT_EXIT -eq 1 ] || [ "$IMPACT_GATE" = "WARN" ]; then
        IMPACT_RESULT_VAL="WARN"
    fi
    echo "[IMPACT-GATE] result=$IMPACT_RESULT_VAL mode=$IMPACT_MODE"
    if [ "$IMPACT_RESULT_VAL" = "BLOCK" ] && [ "$IMPACT_MODE" = "fail" ]; then
        echo "[IMPACT-GATE] BLOCKED: 수정되지 않은 참조 파일 6건 이상"
        exit 1
    fi
else
    echo "[IMPACT-GATE] disabled — 스킵."
fi

# 2.6.5. CI Preflight Gate
CI_RESULT_VAL="PASS"
CI_ENABLED=$(python3 -c "from utils.gate_config_loader import is_gate_enabled; print(is_gate_enabled('ci_preflight'))" 2>/dev/null || echo "false")
if [ "$CI_ENABLED" = "True" ]; then
    CI_MODE=$(python3 -c "from utils.gate_config_loader import get_gate_mode; print(get_gate_mode('ci_preflight'))" 2>/dev/null || echo "warn")
    set +e
    bash scripts/ci_preflight.sh "$PROJ_DIR" --affected-only "$COMMIT_COUNT"
    CI_EXIT=$?
    set -e
    if [ $CI_EXIT -eq 0 ]; then
        CI_RESULT_VAL="PASS"
    elif [ $CI_EXIT -eq 2 ]; then
        CI_RESULT_VAL="WARN"
    else
        CI_RESULT_VAL="FAIL"
    fi
    echo "[CI-PREFLIGHT] exit=$CI_EXIT result=$CI_RESULT_VAL mode=$CI_MODE"
    if [ "$CI_RESULT_VAL" = "FAIL" ] && [ "$CI_MODE" = "fail" ]; then
        echo "[CI-PREFLIGHT] BLOCKED: CI 테스트 실패"
        exit 1
    fi
else
    echo "[CI-PREFLIGHT] disabled — 스킵."
fi

# 2.6.9. Playwright Chrome 좀비 정리 (task-2324)
echo "[cleanup] Playwright Chrome 프로세스 정리..."
pkill -f "ms-playwright.*chromium.*chrome" 2>/dev/null || true
pkill -f "ms-playwright.*chrome-headless-shell" 2>/dev/null || true
sleep 1
# 강제 kill (SIGKILL) - graceful shutdown 실패 시
pkill -9 -f "ms-playwright.*chromium.*chrome" 2>/dev/null || true
pkill -9 -f "ms-playwright.*chrome-headless-shell" 2>/dev/null || true
echo "[cleanup] Playwright Chrome 정리 완료"

# 2.6.10. 워크트리 백그라운드 프로세스 cleanup (task-2350)
# 봇이 워크트리에서 띄운 dev 서버(npm run dev, vite, uvicorn, playwright 등) 자동 종료
# 안전 가드: /.worktrees/<task-id>-<team>/ 부분 문자열 매칭으로만 종료 (시스템 전역 dev 서버 보존)
WORKTREE_SUBSTR=""
if [ -n "$PROJECT_PATH" ] && [[ "$PROJECT_PATH" == *"/.worktrees/"* ]]; then
    # 1차: PROJECT_PATH 자체가 워크트리 경로
    WORKTREE_SUBSTR="$PROJECT_PATH"
elif [ -n "$TASK_ID" ] && [ -n "$TEAM_SHORT" ]; then
    # 2차: 패턴 매칭 (.worktrees/<task-id>-<team>)
    WORKTREE_SUBSTR=".worktrees/${TASK_ID}-${TEAM_SHORT}"
fi

if [ -n "$WORKTREE_SUBSTR" ]; then
    echo "[bg-cleanup] 워크트리 백그라운드 프로세스 정리 시작: $WORKTREE_SUBSTR"

    # 자기 자신/조상 PID 수집 (kill 제외)
    SELF_PID=$$
    EXCLUDE_PIDS=" $SELF_PID "
    _cur_pid=$SELF_PID
    while [ "$_cur_pid" -gt 1 ]; do
        _ppid=$(awk '/^PPid:/ {print $2}' /proc/$_cur_pid/status 2>/dev/null || echo "")
        if [ -z "$_ppid" ] || [ "$_ppid" = "0" ] || [ "$_ppid" = "1" ]; then
            break
        fi
        EXCLUDE_PIDS="$EXCLUDE_PIDS$_ppid "
        _cur_pid=$_ppid
    done

    # /proc/<pid>/cwd 심볼릭 링크에서 워크트리 경로 매칭하는 PID 수집
    BG_PIDS=""
    for pid_dir in /proc/[0-9]*; do
        pid=$(basename "$pid_dir")
        # 자기 자신/조상 제외
        case "$EXCLUDE_PIDS" in
            *" $pid "*) continue ;;
        esac
        cwd=$(readlink "$pid_dir/cwd" 2>/dev/null || true)
        if [ -n "$cwd" ] && [[ "$cwd" == *"$WORKTREE_SUBSTR"* ]]; then
            BG_PIDS="$BG_PIDS $pid"
        fi
    done

    BG_COUNT=0
    for _ in $BG_PIDS; do BG_COUNT=$((BG_COUNT + 1)); done

    if [ "$BG_COUNT" -gt 0 ]; then
        echo "[bg-cleanup] 종료 대상 PID:$BG_PIDS (총 ${BG_COUNT}개)"
        # 자식 프로세스 트리 SIGTERM
        for pid in $BG_PIDS; do
            pkill -TERM -P "$pid" 2>/dev/null || true
            kill -TERM "$pid" 2>/dev/null || true
        done
        # graceful 대기
        sleep 5
        # SIGKILL 폴백
        KILLED_HARD=0
        for pid in $BG_PIDS; do
            if kill -0 "$pid" 2>/dev/null; then
                pkill -KILL -P "$pid" 2>/dev/null || true
                kill -KILL "$pid" 2>/dev/null || true
                KILLED_HARD=$((KILLED_HARD + 1))
            fi
        done
        echo "[bg-cleanup] 워크트리 백그라운드 프로세스 ${BG_COUNT}개 종료 완료 (SIGKILL 폴백: ${KILLED_HARD}개)"
    else
        echo "[bg-cleanup] 워크트리에서 실행 중인 백그라운드 프로세스 없음"
    fi
else
    echo "[bg-cleanup] WORKTREE_SUBSTR 미감지 — 스킵 (시스템 작업 또는 비-워크트리)"
fi

# 2.7. 팀원 전원 idle 복원 (member-status.json)
python3 -c "
import json
from datetime import datetime, timezone
status_file = '$WORKSPACE/memory/events/member-status.json'
try:
    with open(status_file) as f:
        data = json.load(f)
    now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
    changed = False
    for name, info in data.get('members', {}).items():
        if info.get('status') in ('working', 'standby'):
            task_desc = info.get('task', '') or ''
            if '$TASK_ID' in task_desc or not task_desc or info.get('status') == 'standby':
                info['status'] = 'idle'
                info['since'] = now
                info['task'] = None
                changed = True
    if changed:
        data['updated_at'] = now
        with open(status_file, 'w') as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
        print('[INFO] member-status.json: working/standby → idle 복원 완료')
    else:
        print('[INFO] member-status.json: 복원 대상 없음')
except Exception as e:
    print(f'[WARN] member-status idle 복원 실패: {e}')
" 2>&1 || echo "[WARN] member-status idle 복원 스킵"

# 2.8. G3 독립 검증 (Lv.3+ 작업에만 적용)
TASK_LEVEL=$(python3 -c "
import json, re
workspace = '$WORKSPACE'
task_id = '$TASK_ID'

# 1차: task-timers.json work_level
level = ''
try:
    with open(f'{workspace}/memory/task-timers.json') as f:
        data = json.load(f)
    tasks = data.get('tasks', data)
    t = tasks.get(task_id, {})
    level = (t.get('work_level', '') or '').strip().lower()
except Exception:
    pass

# 2차: task 파일 ## 레벨 섹션 파싱
if not level:
    task_file = f'{workspace}/memory/tasks/{task_id}.md'
    try:
        with open(task_file) as f:
            content = f.read()
        m = re.search(r'## 레벨\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL)
        if m:
            section = m.group(1).strip().lower()
            if 'critical' in section:
                level = 'critical'
            elif 'security' in section:
                level = 'security'
            elif re.search(r'lv\.?4|level\s*4', section):
                level = 'lv4'
            elif re.search(r'lv\.?3|level\s*3', section):
                level = 'lv3'
    except Exception:
        pass

# Lv.3+ 판정
lv3_keywords = ('critical', 'security', 'lv3', 'lv4', 'lv.3', 'lv.4')
is_lv3 = any(kw in level for kw in lv3_keywords)
if not is_lv3 and level:
    import re as _re
    is_lv3 = bool(_re.search(r'level\s*[34]', level))
print('lv3plus' if is_lv3 else 'below')
" 2>/dev/null || echo "below")

if [ "$TASK_LEVEL" = "lv3plus" ]; then
    echo "[G3-GATE] Lv.3+ 감지 — g3_independent_verifier 실행."
    set +e
    python3 "$WORKSPACE/scripts/g3_independent_verifier.py" --task-id "$TASK_ID" 2>&1
    G3_EXIT=$?
    set -e
    if [ "$G3_EXIT" -ne 0 ]; then
        echo "[G3-GATE] FAIL — .done 생성 차단. g3_verifier exit code=$G3_EXIT"
        # .g3-failed 이벤트 생성 (QC FAIL 패턴과 동일)
        echo '{"task_id":"'"$TASK_ID"'","team":"'"$TEAM_SHORT"'","fail_reason":"G3 FAIL","timestamp":"'"$(date -Iseconds)"'"}' > "$EVENTS_DIR/${TASK_ID}.g3-failed"
        exit 1
    fi
    echo "[G3-GATE] PASS — g3 검증 통과."
else
    echo "[G3-GATE] Lv.2 이하 — g3_verifier 스킵."
fi

# 2.8.5. [G4-GATE] Pre-PR Gemini CLI 단발 gate (task-2562, OAuth-personal)
# 회장 §명시: G3 PASS 직후, .done 이전, Lv.4 보안 감사 이전.
# Lv.1~2 soft (warning + PR open 허용) / Lv.2 risk-trigger hard / Lv.3+ hard
# fix_loop_count max=2 / scope_violation 즉시 ESCALATED / OAuth-personal 강제.
if [ "${G4_GATE_ENABLED:-1}" = "1" ] && [ -f "$WORKSPACE/scripts/gemini_cli_gate_check.py" ]; then
    echo "[G4-GATE] Pre-PR Gemini CLI 단발 gate 실행 (task=$TASK_ID, level=$TASK_LEVEL)"
    # gemini comment(2026-05-12): --affected-files / --expected-files 전달.
    # ACTUAL_CHANGED_FILES / EXPECTED_FILES 가 상위 step에서 export 되었으면 사용.
    G4_AFFECTED_ARGS=()
    if [ -n "${ACTUAL_CHANGED_FILES:-}" ]; then
        # shellcheck disable=SC2206
        G4_AFFECTED_ARGS=(--affected-files ${ACTUAL_CHANGED_FILES})
    fi
    G4_EXPECTED_ARGS=()
    if [ -n "${EXPECTED_FILES:-}" ]; then
        # shellcheck disable=SC2206
        G4_EXPECTED_ARGS=(--expected-files ${EXPECTED_FILES})
    fi
    set +e
    python3 "$WORKSPACE/scripts/gemini_cli_gate_check.py" \
        --task-id "$TASK_ID" \
        --workspace-root "$WORKSPACE" \
        "${G4_AFFECTED_ARGS[@]}" \
        "${G4_EXPECTED_ARGS[@]}" \
        2>&1
    G4_EXIT=$?
    set -e
    if [ "$G4_EXIT" -eq 2 ]; then
        echo "[G4-GATE] ESCALATED — scope_violation 또는 fix_loop_cap. .done 차단."
        exit 1
    elif [ "$G4_EXIT" -eq 1 ]; then
        echo "[G4-GATE] hard FAIL — .done 생성 차단 + PR open 금지."
        exit 1
    else
        echo "[G4-GATE] PASS 또는 soft (PR open 허용)."
    fi
else
    echo "[G4-GATE] disabled or gate script missing — skipped."
fi

# 2.9. Lv.4 보안 감사 검증 (WARNING only — 첫 단계)
if [ "$TASK_LEVEL" = "lv3plus" ]; then
    IS_LV4=$(python3 -c "
import re
task_file = '$WORKSPACE/memory/tasks/$TASK_ID.md'
try:
    with open(task_file) as f:
        content = f.read()
    m = re.search(r'## 레벨\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL)
    if m:
        section = m.group(1).strip().lower()
        if 'critical' in section or 'security' in section or re.search(r'lv\.?4|level\s*4', section):
            print('yes')
        else:
            print('no')
    else:
        print('no')
except Exception:
    print('no')
" 2>/dev/null || echo "no")

    if [ "$IS_LV4" = "yes" ]; then
        echo "[LV4-AUDIT] Lv.4 감지 — 보안 감사 검증 시작."

        # 2.9.1 security-audit 이벤트 파일 체크
        SECURITY_AUDIT_FILE="$EVENTS_DIR/${TASK_ID}.security-audit"
        if [ ! -f "$SECURITY_AUDIT_FILE" ]; then
            echo "[LV4-AUDIT] ⚠️ WARNING: security-audit 파일 없음 ($SECURITY_AUDIT_FILE)"
            echo "[LV4-AUDIT] Lv.4 작업은 보안 감사 결과 파일이 필수입니다. .done 차단."
            exit 1
        else
            echo "[LV4-AUDIT] ✅ security-audit 파일 존재 확인."
        fi

        # 2.9.2 로키 보안 감사 참여 기록 체크 (보고서에서)
        REPORT_FILE="$WORKSPACE/memory/reports/${TASK_ID}.md"
        if [ -f "$REPORT_FILE" ]; then
            LOKI_SECURITY=$(python3 -c "
import re
report_path = '$REPORT_FILE'
try:
    with open(report_path) as f:
        content = f.read()
    has_loki = bool(re.search(r'로키|Loki|loki', content))
    has_security = bool(re.search(r'보안 감사|security audit|레드팀|red.?team', content, re.IGNORECASE))
    if has_loki and has_security:
        print('PASS')
    elif has_loki:
        print('PARTIAL')
    else:
        print('MISSING')
except Exception:
    print('ERROR')
" 2>/dev/null || echo "ERROR")

            case "$LOKI_SECURITY" in
                PASS)
                    echo "[LV4-AUDIT] ✅ 로키 보안 감사 참여 기록 확인."
                    ;;
                PARTIAL)
                    echo "[LV4-AUDIT] ⚠️ WARNING: 로키 참여 기록은 있으나 보안 감사 명시 없음."
                    ;;
                MISSING)
                    echo "[LV4-AUDIT] ⚠️ WARNING: 로키 보안 감사 참여 기록 없음."
                    echo "[LV4-AUDIT] Lv.4 작업은 로키 레드팀 보안 감사가 필수입니다. .done 차단."
                    exit 1
                    ;;
                *)
                    echo "[LV4-AUDIT] ⚠️ WARNING: 보고서 파싱 실패."
                    ;;
            esac
        else
            echo "[LV4-AUDIT] ⚠️ WARNING: 보고서 파일 없음 — 로키 참여 확인 불가."
        fi

        echo "[LV4-AUDIT] Lv.4 보안 감사 검증 완료 (FAIL 게이트 적용)."
    fi
fi

# 2.9.5. ANU Normal Callback Registration Enforcement (task-2694+1, 회장 verbatim 2026-05-27)
# 회장 verbatim: "envelope 작성만으로 완료 처리되는 것을 코드로 차단"
# 4-source AND: schedule_id + schedule_history status=ok + ANU owner_key + inbound/receipt
# FAIL 시 marker 자동 발행 (memory/events/<task_id>.normal-callback-not-registered.json) + .done 차단
CALLBACK_ENVELOPE_PATH="${CALLBACK_ENVELOPE_PATH:-$WORKSPACE/memory/events/anu_callback/${TASK_ID}-normal-completion.json}"
NORMAL_CALLBACK_ENFORCE_SKIP=0

# system task (PROJECT_PATH 없음) 와 cancelled/escalated 는 skip
if [ -f "$CANCELLED_FILE" ]; then
    NORMAL_CALLBACK_ENFORCE_SKIP=1
fi

# 봇이 disable env로 우회하는 패턴은 회장 verbatim 금지 (8 우회 패턴)
# DISABLE_NORMAL_CALLBACK_ENFORCE 환경변수는 의도적으로 지원하지 않음.

if [ "$NORMAL_CALLBACK_ENFORCE_SKIP" -eq 0 ]; then
    # envelope 파일 존재 여부 확인 (없으면 envelope-only completion 차단 의미 없음 → SKIP, 단, ANU callback 의무 task만 enforce)
    # 회장 verbatim 의무 대상: Lv.3+ 작업 중 ANU normal callback envelope 가 명시된 경우만
    if [ -f "$CALLBACK_ENVELOPE_PATH" ]; then
        echo "[NORMAL-CALLBACK-ENFORCE] envelope detected: $CALLBACK_ENVELOPE_PATH — 4-source validator 실행"
        set +e
        python3 -m dispatch.normal_fallback_callback_helper enforce \
            --task-id "$TASK_ID" \
            --envelope-path "$CALLBACK_ENVELOPE_PATH" \
            --events-dir "$EVENTS_DIR" 2>&1
        CALLBACK_ENFORCE_EXIT=$?
        set -e
        if [ "$CALLBACK_ENFORCE_EXIT" -ne 0 ]; then
            MARKER_FILE="$EVENTS_DIR/${TASK_ID}.normal-callback-not-registered.json"
            echo "[NORMAL-CALLBACK-ENFORCE] FAIL — exit=$CALLBACK_ENFORCE_EXIT marker=$MARKER_FILE"
            echo "[NORMAL-CALLBACK-ENFORCE] .done 생성 차단 (회장 verbatim 2026-05-27 NORMAL_CALLBACK_REGISTRATION_ENFORCEMENT)"
            # marker 가 발행되었는지 한 번 더 확인 (fail-safe)
            # Gemini PR #155 security-high: env var 전달로 Python injection 방어 (TASK_ID 등에 quote 포함 시 SyntaxError/RCE 회피).
            TASK_ID="$TASK_ID" EVENTS_DIR="$EVENTS_DIR" CALLBACK_ENFORCE_EXIT="$CALLBACK_ENFORCE_EXIT" python3 -c "
import os
from utils.callback_registration_marker import has_not_registered_marker, emit_not_registered_marker
task_id = os.environ.get('TASK_ID', '')
events_dir = os.environ.get('EVENTS_DIR', '')
exit_code = os.environ.get('CALLBACK_ENFORCE_EXIT', '')
if not has_not_registered_marker(task_id, events_dir):
    em = emit_not_registered_marker(
        task_id=task_id,
        reason=f'finish-task.sh callback enforce exit={exit_code} (validator 외부 호출)',
        events_dir=events_dir,
    )
    print(f'[NORMAL-CALLBACK-ENFORCE] fail-safe marker emitted: {em.marker_path}')
" 2>&1 || true
            exit 1
        fi
        echo "[NORMAL-CALLBACK-ENFORCE] PASS — 4-source validator 통과 (schedule_id+schedule_history+ANU owner_key+inbound)"
    else
        # envelope 파일 부재. Lv.3+ 작업이고 task md 에 ANU normal callback 의무가 명시되어 있으면 FAIL.
        # 일반 작업은 envelope 미생성이 정상 → SKIP.
        # ★ envelope-only completion 차단 doctrine: envelope 자체가 없으면 envelope-only 가 아니므로 본 게이트 적용 대상 아님.
        echo "[NORMAL-CALLBACK-ENFORCE] envelope 부재 ($CALLBACK_ENVELOPE_PATH) — 본 게이트 SKIP (envelope-only 차단 doctrine은 envelope 존재 시에만 동작)"
    fi
else
    echo "[NORMAL-CALLBACK-ENFORCE] cancelled/skipped — 본 게이트 SKIP"
fi

# 2.10. Codex 사전검증 결과 파일 체크 (Lv.3+ 작업에만 적용)
if [ "$TASK_LEVEL" = "lv3plus" ]; then
    CODEX_GATE_FILE="$EVENTS_DIR/${TASK_ID}.codex-gate"
    if [ -f "$CODEX_GATE_FILE" ]; then
        CODEX_PASS=$(python3 -c "
import json
try:
    with open('$CODEX_GATE_FILE') as f:
        data = json.load(f)
    print('PASS' if data.get('pass', False) else 'FAIL')
except Exception:
    print('UNKNOWN')
" 2>/dev/null || echo "UNKNOWN")
        echo "[CODEX-GATE] Codex 사전검증 결과 파일 존재: $CODEX_PASS"
    else
        echo "[CODEX-GATE] WARNING: Codex 사전검증 결과 파일 없음 ($CODEX_GATE_FILE). Codex 게이트가 실행되지 않았습니다."
    fi
else
    echo "[CODEX-GATE] Lv.2 이하 — Codex 게이트 체크 스킵."
fi

# 2.11. Unresolved Issue Gate
UNRESOLVED_RESULT_VAL="PASS"
UNRESOLVED_ENABLED=$(python3 -c "from utils.gate_config_loader import is_gate_enabled; print(is_gate_enabled('unresolved_gate'))" 2>/dev/null || echo "false")
if [ "$UNRESOLVED_ENABLED" = "True" ]; then
    UNRESOLVED_MODE=$(python3 -c "from utils.gate_config_loader import get_gate_mode; print(get_gate_mode('unresolved_gate'))" 2>/dev/null || echo "warn")
    if [ -f "$REPORT_FILE" ]; then
        UNRESOLVED_COUNT=$(grep -ciE "범위 내 미해결|in.scope.*unresolved" "$REPORT_FILE" 2>/dev/null | tail -1 || echo "0")
        UNRESOLVED_COUNT="${UNRESOLVED_COUNT:-0}"
        MAX_UNRESOLVED=$(python3 -c "from utils.gate_config_loader import load_gate_config; print(load_gate_config('unresolved_gate').get('max_in_scope_unresolved', 3))" 2>/dev/null || echo "3")
        echo "[UNRESOLVED-GATE] count=$UNRESOLVED_COUNT max=$MAX_UNRESOLVED mode=$UNRESOLVED_MODE"
        if [ "$UNRESOLVED_COUNT" -gt "$MAX_UNRESOLVED" ]; then
            UNRESOLVED_RESULT_VAL="BLOCK"
            if [ "$UNRESOLVED_MODE" = "fail" ]; then
                echo "[UNRESOLVED-GATE] BLOCKED"
                exit 1
            fi
        elif [ "$UNRESOLVED_COUNT" -gt 0 ]; then
            UNRESOLVED_RESULT_VAL="WARN"
        fi
    fi
else
    echo "[UNRESOLVED-GATE] disabled — 스킵."
fi

# 2.12. Goal Assertions Gate
GOAL_RESULT_VAL="PASS"
GOAL_ENABLED=$(python3 -c "from utils.gate_config_loader import is_gate_enabled; print(is_gate_enabled('goal_assertions'))" 2>/dev/null || echo "false")
if [ "$GOAL_ENABLED" = "True" ]; then
    GOAL_MODE=$(python3 -c "from utils.gate_config_loader import get_gate_mode; print(get_gate_mode('goal_assertions'))" 2>/dev/null || echo "fail")
    if [ -f "$TASK_FILE" ]; then
        GOALS=$(python3 -c "
import re
with open('$TASK_FILE') as f:
    content = f.read()
m = re.search(r'## goal_assertions.*?\n(.*?)(?=\n##|\Z)', content, re.S)
if m:
    for line in m.group(1).strip().split('\n'):
        cmd = re.search(r'\x60([^\x60]+)\x60', line)
        if cmd:
            print(cmd.group(1))
" 2>/dev/null)
        # allowed_commands 화이트리스트 로드
        ALLOWED_CMDS=$(python3 -c "from utils.gate_config_loader import load_gate_config; print(' '.join(load_gate_config('goal_assertions').get('allowed_commands', [])))" 2>/dev/null || echo "grep curl pytest python3 tsc cat jq npx npm")
        GOAL_FAIL=0
        while IFS= read -r cmd; do
            [ -z "$cmd" ] && continue
            # 명령의 첫 단어 추출 및 화이트리스트 검증
            FIRST_WORD=$(echo "$cmd" | awk '{print $1}')
            ALLOWED=0
            for acmd in $ALLOWED_CMDS; do
                if [ "$FIRST_WORD" = "$acmd" ]; then
                    ALLOWED=1
                    break
                fi
            done
            if [ $ALLOWED -eq 0 ]; then
                echo "[GOAL-GATE] SKIP (not in allowed_commands): $cmd"
                continue
            fi
            set +e
            eval "$cmd" > /dev/null 2>&1
            CMD_EXIT=$?
            set -e
            if [ $CMD_EXIT -ne 0 ]; then
                echo "[GOAL-GATE] FAIL: $cmd"
                GOAL_FAIL=1
            fi
        done <<< "$GOALS"
        if [ $GOAL_FAIL -eq 1 ]; then
            GOAL_RESULT_VAL="FAIL"
            if [ "$GOAL_MODE" = "fail" ]; then
                echo "[GOAL-GATE] BLOCKED: goal_assertions 미충족"
                exit 1
            fi
        fi
    fi
else
    echo "[GOAL-GATE] disabled — 스킵."
fi

# l1_smoketest 결과 수집 (.qc-result에서)
L1_RESULT_VAL="PASS"
if [ -f "$QC_RESULT_FILE" ]; then
    L1_FROM_QC=$(python3 -c "
import json
try:
    with open('$QC_RESULT_FILE') as f:
        data = json.load(f)
    checks = data.get('checks_summary', {})
    l1 = checks.get('l1_smoketest_check', checks.get('l1_smoketest', 'PASS'))
    print(l1)
except Exception:
    print('PASS')
" 2>/dev/null || echo "PASS")
    if [ "$L1_FROM_QC" = "FAIL" ]; then
        L1_RESULT_VAL="FAIL"
    fi
fi

# task-2468 P0-1: .g3-fail 마커 차단 (G3 gate와 별개로 .done 차단 한 번 더)
python3 -c "
import sys
sys.path.insert(0, '$WORKSPACE/scripts')
from lifecycle_guards import check_g3_fail_blocks_done
r = check_g3_fail_blocks_done('$TASK_ID')
if not r.ok:
    print(f'[GUARD] .done 차단 (P0-1): {r.reason}')
    sys.exit(1)
" || { echo "[GUARD] G3 fail marker 검출 — .done 차단"; exit 1; }

# 3. .done 원자적 생성 (qc_result 포함)
if ! (set -C; python3 -c "
import json, sys
data = {
    'task_id': '$TASK_ID',
    'team': '$TEAM_SHORT',
    'qc_result': '$QC_STATUS',
    'completed_at': '$(date -Iseconds)',
    'status': 'done',
    'gate_results': {
        'impact_scanner': '$IMPACT_RESULT_VAL',
        'ci_preflight': '$CI_RESULT_VAL',
        'l1_smoketest': '$L1_RESULT_VAL',
        'goal_assertions': '$GOAL_RESULT_VAL',
        'unresolved_gate': '$UNRESOLVED_RESULT_VAL',
    },
}
with open('$DONE_FILE', 'x') as f:
    json.dump(data, f, ensure_ascii=False, indent=2)
") 2>/dev/null; then
    echo "[WARN] .done already exists: $DONE_FILE"
fi

# ── task-2569 RC-3: stash lifecycle audit (종료) ──
_STASH_AUDIT_AFTER=$(git stash list 2>/dev/null | wc -l)
mkdir -p "$WORKSPACE/memory/logs"
python3 - "$_STASH_AUDIT_AFTER" "${TASK_ID:-unknown}" <<'PYEOF' >> "$WORKSPACE/memory/logs/cleanup-audit.jsonl" 2>/dev/null || true
import json, sys, time
print(json.dumps({"ts": time.time(), "audit": "finish-task-end", "task_id": sys.argv[2], "stash_count": int(sys.argv[1]), "stash_origin_metadata": {"source": "finish-task", "caller_script": "finish-task.sh", "reason": "audit-post-finish", "task_id": sys.argv[2]}}, ensure_ascii=False))
PYEOF
if [ -f "$WORKSPACE/scripts/stash_audit.py" ]; then
    python3 "$WORKSPACE/scripts/stash_audit.py" --workspace "$WORKSPACE" --json 2>/dev/null \
        | python3 - "$TASK_ID" "end" <<'PYEOF' >> "$WORKSPACE/memory/logs/cleanup-audit.jsonl" 2>/dev/null || true
import json, sys
from datetime import datetime, timezone
obj = json.load(sys.stdin)
task_id = sys.argv[1]
phase = sys.argv[2]
print(json.dumps({
    "ts_utc": datetime.now(timezone.utc).isoformat(),
    "audit": "stash-origin-audit",
    "phase": phase,
    "task_id": task_id,
    "summary": obj.get("summary", {}),
}, ensure_ascii=False))
PYEOF
fi

# ── task-2571: stash lifecycle dispatch (분류별 명시적 처리) ──
# spec: memory/specs/stash-lifecycle.md
# 기본: dry-run. FINISH_TASK_STASH_APPROVE=1 시 분류별 정책 실행.
if [ -f "$WORKSPACE/scripts/stash_audit.py" ]; then
    python3 - \
        "$TASK_ID" \
        "${FINISH_TASK_STASH_APPROVE:-0}" \
        "${FINISH_TASK_STASH_INDICES:-}" \
        "${FINISH_TASK_STASH_DEBUG:-0}" \
        "$WORKSPACE" \
        "$DONE_FILE" \
        "$STASH_AUDIT_TIMEOUT_SEC" \
        <<'PYEOF' || { echo "[ERROR] stash-lifecycle dispatch failed (fatal)" >&2; exit 1; }
import json, os, subprocess, sys
from datetime import datetime, timezone
from pathlib import Path

task_id = sys.argv[1]
approve = sys.argv[2] == "1"
indices_raw = sys.argv[3]
debug = sys.argv[4] == "1"
workspace = sys.argv[5]
done_file = sys.argv[6]
audit_timeout = int(sys.argv[7])

# stash_audit.py JSON 호출
res = subprocess.run(
    ["python3", f"{workspace}/scripts/stash_audit.py", "--workspace", workspace, "--json"],
    capture_output=True, text=True, timeout=audit_timeout,
)
if res.returncode != 0 or not res.stdout.strip():
    print(f"[stash-lifecycle] FAIL-STOP — stash_audit.py invocation failed (rc={res.returncode}, stderr={res.stderr.strip()})", file=sys.stderr)
    sys.exit(1)
audit = json.loads(res.stdout)
entries = audit.get("entries", [])

drop_indices = set()
if indices_raw:
    for s in indices_raw.split(","):
        s = s.strip()
        if s.isdigit():
            drop_indices.add(int(s))

pr_verified = os.path.exists(done_file)
decisions = []
unknown_kept = 0
fail_stop = False

def _git(args):
    return subprocess.run(["git"] + args, cwd=workspace, capture_output=True, text=True)

# 역순 처리 (높은 index 부터 pop/drop — index shift 안전)
for e in sorted(entries, key=lambda x: -x["index"]):
    idx = e["index"]
    src = e["source"]
    etid = e.get("task_id")
    reason = e.get("reason", "")
    policy = ""
    action = ""
    exit_code = 0

    if src == "pre-task":
        policy = "auto-pop"
        if approve:
            r = _git(["stash", "pop", f"stash@{{{idx}}}"])
            exit_code = r.returncode
            if r.returncode == 0:
                action = "popped"
            else:
                action = "failed"
                fail_stop = True
                decision = {
                    "index": idx, "source": src, "task_id": etid,
                    "reason": reason, "policy": policy, "action": action,
                    "exit_code": exit_code, "stderr": r.stderr.strip(),
                }
                decisions.append(decision)
                break
        else:
            action = "dry-run-pop"
    elif src == "finish-task":
        policy = "verified-auto-pop"
        if approve and pr_verified and etid == task_id:
            r = _git(["stash", "pop", f"stash@{{{idx}}}"])
            exit_code = r.returncode
            if r.returncode == 0:
                action = "popped"
            else:
                action = "failed"
                fail_stop = True
                decision = {
                    "index": idx, "source": src, "task_id": etid,
                    "reason": reason, "policy": policy, "action": action,
                    "exit_code": exit_code, "stderr": r.stderr.strip(),
                }
                decisions.append(decision)
                break
        elif approve and (not pr_verified or etid != task_id):
            action = "preserved"
        else:
            action = "dry-run-pop"
    elif src == "other-files":
        policy = "explicit-drop"
        if approve and idx in drop_indices:
            r = _git(["stash", "drop", f"stash@{{{idx}}}"])
            exit_code = r.returncode
            if r.returncode == 0:
                action = "dropped"
            else:
                action = "failed"
                fail_stop = True
                decision = {
                    "index": idx, "source": src, "task_id": etid,
                    "reason": reason, "policy": policy, "action": action,
                    "exit_code": exit_code, "stderr": r.stderr.strip(),
                }
                decisions.append(decision)
                break
        else:
            action = "dry-run-drop"
    elif src == "wip":
        policy = "explicit-preserve"
        action = "preserved"
    elif src == "quarantine":
        policy = "explicit-preserve"
        action = "preserved"
    elif src == "unknown":
        policy = "quarantine-cleanup-forbidden"
        action = "preserved"
        unknown_kept += 1
    else:
        policy = "fallback-preserve"
        action = "preserved"

    decisions.append({
        "index": idx,
        "source": src,
        "task_id": etid,
        "reason": reason,
        "policy": policy,
        "action": action,
        "exit_code": exit_code,
    })

# spec §3.2.2.3: 실패 시 stop — fail_stop 여부 기록
# audit log 박제
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
log_path = Path(workspace) / "memory" / "events" / f"stash-lifecycle-action.{ts}.json"
log_path.parent.mkdir(parents=True, exist_ok=True)

after = _git(["stash", "list"])
after_count = len([l for l in after.stdout.splitlines() if l.strip()])

payload = {
    "timestamp_utc": datetime.now(timezone.utc).isoformat(),
    "task_id": task_id,
    "approval_mode": "approved" if approve else "dry-run",
    "pr_verified": pr_verified,
    "stash_count_before": len(entries),
    "stash_count_after": after_count,
    "decisions": decisions,
    "skipped_unknown_count": unknown_kept,
    "fail_stop": fail_stop,
    "notes": f"unknown {unknown_kept}건 preserved — manual review required" if unknown_kept else "",
}
with open(log_path, "w", encoding="utf-8") as f:
    json.dump(payload, f, ensure_ascii=False, indent=2)
if debug:
    print(f"[stash-lifecycle] log: {log_path}", file=sys.stderr)
if fail_stop:
    print(f"[stash-lifecycle] FAIL-STOP — destructive action failed (spec §3.2.2.3)", file=sys.stderr)
    sys.exit(1)
PYEOF
fi

# 4. task-timer end에 qc_result 전달
_timer_ended=1
if [ -n "$QC_STATUS" ]; then
    python3 "$WORKSPACE/memory/task-timer.py" end "$TASK_ID" --qc-result "$QC_STATUS" 2>&1 || echo "[WARN] task-timer end failed"
else
    python3 "$WORKSPACE/memory/task-timer.py" end "$TASK_ID" 2>&1 || echo "[WARN] task-timer end failed"
fi

# 4.5. token-tracker scan + enrich (토큰 사용량 task-timers.json에 기록)
python3 "$WORKSPACE/scripts/token-tracker.py" scan 2>&1 | tail -1 || echo "[WARN] token-tracker scan failed"
python3 "$WORKSPACE/scripts/token-tracker.py" enrich 2>&1 | tail -1 || echo "[WARN] token-tracker enrich failed"

# 5. notify-completion.py
source "$WORKSPACE/.env.keys" 2>/dev/null || true
python3 "$WORKSPACE/scripts/notify-completion.py" "$TASK_ID" 2>&1 || echo "[WARN] notify-completion failed"

# 5.5. [task-2720 P0-a] executor result JSON 작성 — ANU key literal 제거, self-fire 0.
#   executor 완료조건 = result JSON 작성 only (NEED 제거).
#   ANU key 는 COKACDIR_KEY_ANU 환경변수로만 로드 (sealed-key 원칙 — literal 노출 0).
#   callback schedule 생성/발사는 executor 가 하지 않는다.
#   별도 ANU-key runner (P0-b) 가 result JSON 을 pickup 하여 ANU-owned callback 발사.
( cd "$WORKSPACE" && PYTHONPATH="$WORKSPACE" python3 -m dispatch.anu_owned_callback_enforcement \
    executor-write-result \
    --task-id "$TASK_ID" \
    --result-dir "$WORKSPACE/memory/events" \
    --report-path "memory/reports/${TASK_ID}.md" ) \
    && echo "[task-2720] executor result JSON written: $WORKSPACE/memory/events/${TASK_ID}.result.json" \
    || echo "[task-2720] executor result JSON write: fail-closed/non-blocking (ANU runner pickup 대기)"

# 6. 봇 → 아누 후속 알림 (task-2360)
# 보고서 + .followup.txt 기반 회장결정/머지/미해결/다음단계 cron 발송.
# .anu-notified 마커로 중복 방지. 환경변수 DISABLE_ANU_FOLLOWUP=1 시 스킵.
if [ "${DISABLE_ANU_FOLLOWUP:-0}" != "1" ]; then
    python3 "$WORKSPACE/scripts/extract_followup.py" send "$TASK_ID" 2>&1 \
        | tail -2 \
        || echo "[WARN] anu-followup send failed (non-fatal)"
fi

echo "[OK] finish-task.sh completed for $TASK_ID"
