#!/usr/bin/env bash
# reconcile.sh — multi-agent-mux-monitor 의 부속 스크립트
# YAML ↔ tmux ↔ 디스크 artifact 간 drift 감지 (+ YAML 자동 갱신).
#
# Usage:
#   bash reconcile.sh --once --emit-diff            # drift 감지 + 갱신
#   bash reconcile.sh --once --emit-diff --dry-run  # drift 만 계산, 쓰기 안 함 (P1-E)
#
# --dry-run: 부수효과 없는 read-only. "지금 뭐 돌고 있지?" 질문에 안전.
#            multi-agent-mux-status 스킬이 이걸 재사용.
#
# 출력 (JSON): {timestamp, yaml_path, tmux_sessions_alive, tmux_confirmed, drifts, actions}
#
# Exit codes: 0 = ok | 1 = YAML not found | 2 = error
set -euo pipefail

source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
export WORKSPACE_ROOT

STATE_DIR="${AGENT_SESSIONS_STATE_DIR:-$WORKSPACE_ROOT/.cache/multi-agent-mux-monitor}"

ONCE=0
EMIT_DIFF=0
DRY_RUN=0
SUBSCRIBE=0
# --subscribe controls (review item 4): 0 = no overall timeout; idle default 3600s
# (raised from 600s to align with job timeout defaults); idle 0 = never idle-out.
SUB_TIMEOUT=0
SUB_IDLE_TIMEOUT=3600
POLL_INTERVAL="${RECONCILE_POLL_INTERVAL:-15}"

while [ $# -gt 0 ]; do
  case "$1" in
    --once) ONCE=1; shift ;;
    --emit-diff) EMIT_DIFF=1; shift ;;
    --dry-run) DRY_RUN=1; shift ;;
    --subscribe) SUBSCRIBE=1; shift ;;
    --timeout) SUB_TIMEOUT="$2"; shift 2 ;;
    --idle-timeout) SUB_IDLE_TIMEOUT="$2"; shift 2 ;;
    -h|--help) echo "Usage: $0 [--once] [--emit-diff] [--dry-run] [--subscribe [--timeout N] [--idle-timeout N]]"; exit 0 ;;
    *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
  esac
done

[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }

if [ "$SUBSCRIBE" = "1" ]; then
  # Paths resolved relative to this script (review item 6): skills/ dir + lib.sh.
  SKILLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
  LIB_SH="$SKILLS_DIR/lib.sh"
  # MQTT client lives in the project venv (has paho). All YAML work is delegated
  # to lib.sh::atomic_dump_yaml, which runs the system python3 (has PyYAML) — so
  # no single interpreter needs both paho and PyYAML (review items 4/5/6).
  PYBIN="$(_delegate_py_bin)"

  # The MQTT subscribe loop exits 3 to signal "broker unavailable → poll instead".
  set +e
  YAML_PATH="$AGENT_SESSIONS_YAML" HOME_DIR="$HOME_DIR" CLAUDE_PROJECT_DIR="$CLAUDE_PROJECT_DIR" LOCAL_BIN="$LOCAL_BIN" \
    WORKSPACE_ROOT="$WORKSPACE_ROOT" SUB_TIMEOUT="$SUB_TIMEOUT" SUB_IDLE_TIMEOUT="$SUB_IDLE_TIMEOUT" \
    SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" \
    "$PYBIN" - <<'PYEOF'
import os, sys, json, time, subprocess

lib_sh = os.environ.get('LIB_SH', '')
skills_dir = os.environ.get('SKILLS_DIR', '')
yaml_path = os.environ.get('YAML_PATH', '')
workspace_root = os.environ.get('WORKSPACE_ROOT', '')
timeout = int(os.environ.get('SUB_TIMEOUT', '0') or '0')          # 0 = no overall timeout
idle_timeout = int(os.environ.get('SUB_IDLE_TIMEOUT', '3600') or '0')  # 0 = no idle timeout

# Prevent duplicate wildcard subscribers for this workspace (concurrency race)
import fcntl
lock_file_path = os.path.join(workspace_root or '.', '.mam', 'monitor.lock')
try:
    os.makedirs(os.path.dirname(lock_file_path), exist_ok=True)
    lock_file = open(lock_file_path, 'w')
    fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
    print("MQTT Monitor: another subscriber is already running for this workspace. Exiting.", flush=True)
    sys.exit(0)
except Exception as e:
    print(f"MQTT Monitor: failed to acquire monitor lock ({e}). Exiting.", flush=True)
    sys.exit(1)

# Locate skills/multi-agent-mux-delegate-job/scripts to import mqtt_common — relative first, then
# an upward walk from cwd. No hardcoded absolute path (review item 6).
cand = os.path.join(skills_dir, 'multi-agent-mux-delegate-job', 'scripts') if skills_dir else ''
if cand and os.path.isdir(cand):
    sys.path.append(cand)
else:
    d = os.getcwd()
    while d and d != '/':
        hit = None
        for sub in (('.agents', 'skills', 'multi-agent-mux-delegate-job', 'scripts'), ('skills', 'multi-agent-mux-delegate-job', 'scripts'), ('multi-agent-mux-delegate-job', 'scripts')):
            p = os.path.join(d, *sub)
            if os.path.isdir(p):
                hit = p
                break
        if hit:
            sys.path.append(hit)
            break
        d = os.path.dirname(d)

import mqtt_common
import registry

# Executed INSIDE lib.sh::atomic_dump_yaml (system python3 + PyYAML), under the
# YAML flock with schema-validate + .bak (review item 5). Marks matching running
# sessions terminated and kills their tmux (review item 3 behaviour preserved),
# or aborts the write entirely when nothing matches. The untrusted MQTT job id /
# event arrive via env (MQTT_JID / MQTT_EVENT) — never spliced into source (P1-B).
_MUTATION = r'''
import os, subprocess
from datetime import datetime, timezone
_jid = os.environ['MQTT_JID']
_event = os.environ['MQTT_EVENT']
_now = datetime.now(timezone.utc)
_changed = False
for s in d.get('tmux_sessions', []):
    if s.get('delegate_job_id') == _jid and s.get('status') == 'running':
        _name = s.get('name')
        _srv = s.get('tmux_server') or 'default'
        if _event == 'completed':
            s['delegate_job_id'] = None
            print('MQTT Monitor: job completed on ' + str(_name) + ' — session kept alive', flush=True)
            _changed = True
        else:
            s['status'] = 'terminated'
            s['terminated_at'] = _now.strftime('%Y-%m-%dT%H:%M:%SZ')
            s['terminated_at_epoch'] = int(_now.timestamp())
            s['termination_mode'] = 'auto-detected (MQTT ' + _event + ')'
            _cmd = ['tmux'] + (['-L', _srv] if _srv != 'default' else []) + ['kill-session', '-t', _name]
            subprocess.run(_cmd, capture_output=True)
            print('MQTT Monitor: terminated + killed ' + str(_name) + ' on ' + str(_srv) + ' due to MQTT ' + _event, flush=True)
            _changed = True
if not _changed:
    raise SystemExit(0)  # nothing matched — skip the write entirely
'''


def handle_terminal(jid, event):
    if not lib_sh or not os.path.isfile(lib_sh):
        print('MQTT Monitor: lib.sh not found, cannot update YAML', flush=True)
        return
    env = dict(os.environ)
    env['MQTT_JID'] = jid
    env['MQTT_EVENT'] = event
    cmd = ['bash', '-c',
           'source "$LIB_SH"; atomic_dump_yaml "$YAML_PATH" MQTT_JID="$MQTT_JID" MQTT_EVENT="$MQTT_EVENT"']
    r = subprocess.run(cmd, input=_MUTATION, text=True, env=env, capture_output=True)
    if (r.stdout or '').strip():
        print(r.stdout.strip(), flush=True)
    if r.returncode != 0 and (r.stderr or '').strip():
        print('MQTT Monitor: atomic_dump_yaml stderr: ' + r.stderr.strip(), flush=True)


state = {'last_msg': time.time(), 'connected': False, 'failed': False}
last_seqs = {}


def on_message(_client, _userdata, msg):
    state['last_msg'] = time.time()
    try:
        payload = json.loads(msg.payload.decode("utf-8"))
        jid = payload.get("job_id")
        event = payload.get("event")
        if not jid or not event:
            return
        
        if workspace_root:
            registry_dir = os.path.join(workspace_root, '.mam', 'jobs')
        else:
            yaml_dir = os.path.dirname(yaml_path) if yaml_path else ""
            registry_dir = os.path.join(yaml_dir, 'jobs') if yaml_dir else '.mam/jobs'
        
        try:
            job = registry.load_job(jid, registry_dir)
        except FileNotFoundError:
            # Silently ignore events for jobs not in the local registry
            return
            
        expected_token = job.get("auth_token")
        if not mqtt_common.verify_hmac(payload, expected_token):
            print(f"MQTT Monitor: drop event for job {jid}: HMAC verify failed", flush=True)
            return
            
        seq = payload.get("seq")
        if seq is None or not isinstance(seq, int):
            print(f"MQTT Monitor: drop event for job {jid}: missing or invalid seq", flush=True)
            return
        if seq <= last_seqs.get(jid, 0):
            print(f"MQTT Monitor: drop event for job {jid}: seq {seq} not monotonic (last {last_seqs.get(jid, 0)})", flush=True)
            return
        last_seqs[jid] = seq
        
        # Append the event to events.ndjson audit trail
        mqtt_common.append_event(jid, {
            "event": "received",
            "source_event": event,
            "seq": seq,
            "topic": msg.topic,
            "timestamp": payload.get("timestamp"),
            "detail": payload.get("detail", ""),
        })
        
        print(f"MQTT Monitor: recorded event {event} for job {jid} (seq={seq})", flush=True)
        
        if event in ("completed", "error"):
            print(f"MQTT Monitor: received terminal event {event} for job {jid}", flush=True)
            handle_terminal(jid, event)
    except Exception as e:
        print(f"MQTT Monitor error parsing message: {e}", flush=True)


def on_connect(_c, _u, _flags, reason_code, _props):
    rc = mqtt_common.reason_code_value(reason_code)
    if rc == 0:
        state['connected'] = True
        _c.subscribe("python/mqtt/jobs/+/events", qos=1)
        print("MQTT Monitor: subscribed to python/mqtt/jobs/+/events", flush=True)
    else:
        state['failed'] = True
        print(f"MQTT Monitor connection failed: rc={rc}", flush=True)


cfg = mqtt_common.broker_config_from_env()
client = mqtt_common.make_client("monitor_sub", cfg)
client.on_message = on_message
client.on_connect = on_connect
print(f"MQTT Monitor: connecting to {cfg.host}:{cfg.port} (TLS={cfg.tls})...", flush=True)

# Connection failure → fall back to polling (review item 4).
try:
    client.connect(cfg.host, cfg.port, cfg.keepalive)
except Exception as e:
    print(f"MQTT Monitor: connect failed ({e}); falling back to polling", flush=True)
    sys.exit(3)

client.loop_start()
_wait = time.time()
while time.time() - _wait < 5 and not state['connected'] and not state['failed']:
    time.sleep(0.1)
if not state['connected']:
    print("MQTT Monitor: broker did not accept connection; falling back to polling", flush=True)
    client.loop_stop()
    sys.exit(3)

import threading
stop_event = threading.Event()
start = time.time()
try:
    while True:
        now = time.time()
        wall_left = (timeout - (now - start)) if timeout else None
        idle_left = (idle_timeout - (now - state['last_msg'])) if idle_timeout else None
        
        next_timeout = 5.0
        if wall_left is not None:
            next_timeout = min(next_timeout, wall_left)
        if idle_left is not None:
            next_timeout = min(next_timeout, idle_left)
            
        if next_timeout <= 0:
            if wall_left is not None and wall_left <= 0:
                print(f"MQTT Monitor: --timeout {timeout}s reached, exiting", flush=True)
            else:
                print(f"MQTT Monitor: --idle-timeout {idle_timeout}s reached, exiting", flush=True)
            break
            
        stop_event.wait(timeout=next_timeout)
finally:
    client.loop_stop()
    try:
        client.disconnect()
    except Exception:
        pass
sys.exit(0)
PYEOF
  sub_rc=$?
  set -e

  if [ "$sub_rc" = "3" ]; then
    echo "MQTT Monitor: broker unavailable — falling back to polling (interval ${POLL_INTERVAL}s)" >&2
    _self="$SKILLS_DIR/multi-agent-mux-monitor/scripts/reconcile.sh"
    _start=$(date +%s)
    while :; do
      bash "$_self" --once --emit-diff >/dev/null 2>&1 || true
      if [ "$SUB_TIMEOUT" != "0" ] && [ "$(( $(date +%s) - _start ))" -ge "$SUB_TIMEOUT" ]; then
        break
      fi
      sleep "$POLL_INTERVAL"
    done
  fi
  exit 0
fi

mkdir -p "$STATE_DIR"

# 모든 비교 로직을 단일 소스로 둔다. dry-run 은 env_python(읽기전용), 그 외엔
# atomic_dump_yaml(flock + temp+rename) 로 같은 소스를 돌린다. atomic 래퍼에서는
# 'actions' 가 없으면 SystemExit(0) 으로 쓰기를 건너뛴다 (불필요한 재포맷 방지).
read -r -d '' RECON_SRC <<'PYEOF' || true
import os, json, glob, subprocess, time, sqlite3
from datetime import datetime, timezone
import yaml

yaml_path = os.environ['YAML_PATH']
home = os.environ['HOME_DIR']
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")

now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')

try:
    d
except NameError:
    import subprocess
    d = {}
    try:
        ws_root = os.environ.get('WORKSPACE_ROOT')
        if not ws_root:
            ws_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))
        script = f"source '{ws_root}/.agents/skills/lib.sh' && load_state_json"
        out = subprocess.check_output(['bash', '-c', script], stderr=subprocess.DEVNULL)
        d = json.loads(out.decode('utf-8'))
    except Exception:
        pass

drifts = []
actions = []

# === 현재 tmux 상태 — transient 실패를 'no sessions' 와 구분 (P1-E) ===
tmux_sessions = []
tmux_confirmed = True

# YAML 에 등록된 고유한 tmux_server 목록 수집 + 환경변수 TMUX_SERVER_NAME 포함
unique_servers = {'default'}
if 'TMUX_SERVER_NAME' in os.environ:
    unique_servers.add(os.environ['TMUX_SERVER_NAME'])
for s in d.get('tmux_sessions', []):
    srv = s.get('tmux_server') or 'default'
    unique_servers.add(srv)

try:
    for srv in sorted(unique_servers):
        cmd = ['tmux']
        if srv != 'default':
            cmd += ['-L', srv]
        cmd += ['ls', '-F', '#{session_name}|#{session_created}']
        r = subprocess.run(cmd, capture_output=True, text=True)
        if r.returncode == 0:
            for line in r.stdout.strip().split('\n'):
                if not line:
                    continue
                name, created = line.split('|', 1)
                tmux_sessions.append({'name': name, 'created': int(created), 'server': srv})
        else:
            err = (r.stderr or '').lower()
            is_empty = ('no server running' in err) or ('no sessions' in err) or ('failed to connect' in err)
            if not is_empty:
                tmux_confirmed = False
except Exception:
    tmux_confirmed = False


def pane_meta(session, srv):
    try:
        cmd = ['tmux']
        if srv != 'default':
            cmd += ['-L', srv]
        cmd += ['list-panes', '-t', session, '-F',
                '#{pane_pid}|#{pane_current_path}|#{pane_current_command}']
        out = subprocess.check_output(cmd, text=True)
        parts = out.strip().split('\n')[0].split('|')
        return {'pid': int(parts[0]), 'cwd': parts[1], 'cmd': parts[2]}
    except Exception:
        return None


yaml_sessions = d.get('tmux_sessions', [])
yaml_session_names = {s['name'] for s in yaml_sessions if s.get('name')}
alive_set = {(t['name'], t.get('server', 'default')) for t in tmux_sessions}

# === drift A: tmux dead + YAML running → auto-terminate ===
# tmux 응답을 확정했을 때만. transient 실패 시 모두 terminated 로 마크하지 않음 (P1-E)
if tmux_confirmed:
    for s in yaml_sessions:
        name = s.get('name')
        if not name:
            continue
        # 'stopped' 도 deliberate한 종료 상태 — drift 로 보지 않고 그대로 둔다.
        # (없으면 tmux-dead stopped 세션을 'terminated' 로 덮어써 resumable 플래그가 소실됨)
        if s.get('status') in ('terminated', 'archived', 'stopped'):
            continue
        srv = s.get('tmux_server') or 'default'
        if (name, srv) not in alive_set:
            s['status'] = 'terminated'
            s['terminated_at'] = now_iso
            s['terminated_at_epoch'] = int(datetime.now(timezone.utc).timestamp())
            s['termination_mode'] = 'auto-detected (tmux gone)'
            pane = s.get('pane') or {}
            drifts.append({'class': 'A', 'name': name,
                           'msg': f"{name}: tmux gone (was pane {pane.get('pid')}, cmd {pane.get('cmd')}). Marked terminated."})
            actions.append(f"terminated: {name}")

# === drift B: tmux alive + not in YAML → auto-register ===
if tmux_confirmed:
    for t in tmux_sessions:
        name = t['name']
        if name in yaml_session_names:
            continue
        if name.endswith('-creator-claude'):
            agent = 'claude'
        elif name.endswith('-creator-agy'):
            agent = 'agy'
        elif name.endswith('-creator-hermes'):
            agent = 'hermes'
        elif name.endswith('-creator-cline'):
            agent = 'cline'
        else:
            continue
        srv = t.get('server', 'default')
        pm = pane_meta(name, srv)
        if not pm:
            continue
        if agent == 'claude':
            cmd_full = 'claude --dangerously-skip-permissions'
        elif agent == 'agy':
            cmd_full = 'agy --dangerously-skip-permissions'
        elif agent == 'hermes':
            cmd_full = 'hermes'
        elif agent == 'cline':
            cmd_full = 'cline -i'
        server_opt = f"-L {srv} " if srv != 'default' else ""
        entry = {
            'name': name,
            'status': 'running',
            'tmux_session_created_at': datetime.fromtimestamp(t['created'], tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
            'tmux_session_epoch': t['created'],
            'tmux_server': srv,
            'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']},
            # P2: cwd 인용
            'start_command': f'tmux {server_opt}new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"',
            'attach_command': f'tmux {server_opt}attach -t {name}',
            'kill_command': f'tmux {server_opt}kill-session -t {name}',
            'last_visible_status': 'running',
            'last_visible_note': 'auto-registered by monitor',
        }
        if agent == 'claude':
            entry['tui'] = {'model': '(unknown — capture after first message)', 'provider': 'anthropic',
                            'plan': '(unknown)', 'account': '(unknown)', 'version': '(unknown)'}
            entry['claude_session_id_own'] = None
        elif agent == 'agy':
            entry['child_pid'] = 0
            entry['agy_conversation_id_own'] = None
            entry['mcp_attachments'] = [
                {
                    'name': 'stitch',
                    'transport': 'mcp-remote',
                    'endpoint': 'https://stitch.googleapis.com/mcp'
                }
            ]
        elif agent == 'hermes':
            entry['child_pid'] = 0
            entry['hermes_conversation_id_own'] = None
        elif agent == 'cline':
            entry['child_pid'] = 0
            entry['cline_conversation_id_own'] = None
        d.setdefault('tmux_sessions', []).append(entry)
        yaml_session_names.add(name)
        drifts.append({'class': 'B', 'name': name,
                       'msg': f"{name}: tmux found but not in YAML. Auto-registered (pane {pm['pid']}, cmd {pm['cmd']}, cwd {pm['cwd']})."})
        actions.append(f"registered: {name}")

# === drift C: claude 새 session id materialize (per-row own id) ===
for s in d.get('tmux_sessions', []):
    if not s.get('name', '').endswith('-creator-claude'):
        continue
    if s.get('status') != 'running':
        continue
    if s.get('claude_session_id_own'):
        continue
    cwd = (s.get('pane') or {}).get('cwd', '')
    if not cwd:
        continue
    proj_key = cwd.replace('/', '-').replace('_', '-')
    proj_dir = f"{claude_project_dir}/{proj_key}"
    if not os.path.isdir(proj_dir):
        continue
    jsonls = sorted(glob.glob(f"{proj_dir}/*.jsonl"), key=os.path.getmtime, reverse=True)
    if not jsonls:
        continue
    latest = jsonls[0]
    if time.time() - os.path.getmtime(latest) > 300:
        continue
    try:
        with open(latest) as f:
            first = f.readline().strip()
        if not first:
            continue
        sid = json.loads(first).get('sessionId')
        if not sid:
            continue
    except Exception:
        continue
    s['claude_session_id_own'] = sid
    drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: session id materialized: {sid}"})
    actions.append(f"updated session id: {sid}")

# === drift C (agy): agy 새 session id materialize (per-row own id) ===
for s in d.get('tmux_sessions', []):
    if not s.get('name', '').endswith('-creator-agy'):
        continue
    if s.get('status') != 'running':
        continue
    if s.get('agy_conversation_id_own'):
        continue
    cwd = (s.get('pane') or {}).get('cwd', '')
    if not cwd:
        continue
    lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
    if os.path.exists(lc):
        try:
            with open(lc) as f:
                lc_data = json.load(f)
            cid = lc_data.get(cwd)
            if cid and os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{cid}.db"):
                s['agy_conversation_id_own'] = cid
                drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: conversation id materialized: {cid}"})
                actions.append(f"updated conversation id: {cid}")
        except Exception:
            pass

# === drift C (hermes): hermes 새 session id materialize (per-row own id) ===
for s in d.get('tmux_sessions', []):
    if not s.get('name', '').endswith('-creator-hermes'):
        continue
    if s.get('status') != 'running':
        continue
    if s.get('hermes_conversation_id_own'):
        continue
    cwd = (s.get('pane') or {}).get('cwd', '')
    if not cwd:
        continue
    hdb = f"{home}/.hermes/state.db"
    if os.path.exists(hdb):
        try:
            conn = sqlite3.connect(hdb)
            r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (cwd,)).fetchone()
            conn.close()
            if r:
                cid = r[0]
                s['hermes_conversation_id_own'] = cid
                drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: conversation id materialized: {cid}"})
                actions.append(f"updated conversation id: {cid}")
        except Exception:
            pass

# === drift C (cline): cline 새 session id materialize (per-row own id) ===
for s in d.get('tmux_sessions', []):
    if not s.get('name', '').endswith('-creator-cline'):
        continue
    if s.get('status') != 'running':
        continue
    if s.get('cline_conversation_id_own'):
        continue
    cwd = (s.get('pane') or {}).get('cwd', '')
    if not cwd:
        continue
    sessions_dir = f"{home}/.cline/data/sessions"
    if os.path.isdir(sessions_dir):
        candidates = []
        for session_folder in glob.glob(f"{sessions_dir}/*"):
            if os.path.isdir(session_folder):
                folder_name = os.path.basename(session_folder)
                json_file = f"{session_folder}/{folder_name}.json"
                if os.path.exists(json_file):
                    candidates.append(json_file)
        candidates.sort(key=os.path.getmtime, reverse=True)
        for j in candidates:
            try:
                with open(j) as f:
                    sdata = json.load(f)
                if sdata.get('cwd') == cwd or sdata.get('workspace_root') == cwd:
                    cid = sdata.get('session_id')
                    if cid:
                        s['cline_conversation_id_own'] = cid
                        drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: session id materialized: {cid}"})
                        actions.append(f"updated session id: {cid}")
                        break
            except Exception:
                pass

# === drift D: stale UUID (cache 의 artifact 가 사라짐) — 보고만, 변경 없음 ===
ai = d.get('agent_identities', {}) or {}
cl = (ai.get('claude') or {})
if cl.get('session_id'):
    sid = cl['session_id']
    if not glob.glob(f"{claude_project_dir}/*/{sid}.jsonl"):
        drifts.append({'class': 'D', 'name': '(claude identity cache)',
                       'msg': f"stale UUID in agent_identities.claude.session_id: {sid} (jsonl missing)"})
ag = (ai.get('agy') or {})
if ag.get('conversation_id'):
    cid = ag['conversation_id']
    if not os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{cid}.db"):
        drifts.append({'class': 'D', 'name': '(agy identity cache)',
                       'msg': f"stale UUID in agent_identities.agy.conversation_id: {cid} (.db missing)"})
hr = (ai.get('hermes') or {})
if hr.get('session_id'):
    sid = hr['session_id']
    hdb = f"{home}/.hermes/state.db"
    has_session = False
    if os.path.exists(hdb):
        try:
            conn = sqlite3.connect(hdb)
            r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (sid,)).fetchone()
            conn.close()
            has_session = r is not None
        except Exception:
            pass
    if not has_session:
        drifts.append({'class': 'D', 'name': '(hermes identity cache)',
                       'msg': f"stale UUID in agent_identities.hermes.session_id: {sid} (session missing from db)"})
cn = (ai.get('cline') or {})
if cn.get('session_id'):
    sid = cn['session_id']
    if not os.path.exists(f"{home}/.cline/data/sessions/{sid}/{sid}.json"):
        drifts.append({'class': 'D', 'name': '(cline identity cache)',
                       'msg': f"stale UUID in agent_identities.cline.session_id: {sid} (session file missing)"})

result = {
    'timestamp': now_iso,
    'yaml_path': yaml_path,
    'tmux_sessions_alive': sorted(f"{t['name']}|{t.get('server', 'default')}" for t in tmux_sessions),
    'tmux_confirmed': tmux_confirmed,
    'drifts': drifts,
    'actions': actions,
}
print(json.dumps(result, indent=2, ensure_ascii=False))

# atomic 래퍼: actions 가 없으면 쓰기를 건너뛴다. env_python(dry-run)에선 무해.
if not actions:
    raise SystemExit(0)
PYEOF

if [ "$DRY_RUN" = "1" ]; then
  printf '%s' "$RECON_SRC" | env_python "$AGENT_SESSIONS_YAML"
else
  printf '%s' "$RECON_SRC" | atomic_dump_yaml "$AGENT_SESSIONS_YAML"
fi
