# B-4 구현 계획서 Rev.2 — `session_created` 를 실제 POSIX 시각으로

- **Job**: `544ae416` (Rev.1 = `94687096`, Challenge = `7f69fd70` / agy)
- **Role**: Planner (설계 산출물만 작성. 구현·커밋은 Creator/GM 소관 — `MULTI_AGENT_RULES.md` §1)
- **작성일**: 2026-08-08
- **검증 트리**: `scratchpad/b4base`(clean HEAD) · `b4fix`(Rev.1) · `rev2`(Rev.2) · `b4mut`/`b4mut2`(변이)
- **결과**: 테스트 21건 — HEAD 2/21, Rev.1 18/21, **Rev.2 21/21**

---

## 0. 판정 요약

agy 의 지적 3건을 각각 실측으로 판정했다. **전부 채택하되, 셋 다 제시된 메커니즘은 틀렸다.** 그리고 조사 과정에서 양쪽 모두 보지 못한 결함 하나를 찾았는데, 그것이 이 논쟁에서 가장 중요한 항목이다.

| | agy 의 주장 | 판정 | 실측 결과 |
|---|---|---|---|
| **C-1** | F1 적용 시 resume 한 트랜스크립트가 **무조건 거부**된다 | **메커니즘 기각 / 우려는 채택** | resume 경로는 3개 트리 모두에서 **정상 해결**된다. 주장의 3단계(`resume 가 T_resume 를 기록`)는 **일어나지 않는다** — 실측 |
| **C-2** | `ls` 1회당 `4N+2` 포크, batch 처리 필요 | **채택** | Rev.1 은 호출당 **156.0 ms**(HEAD 42.6 ms). 단일 파이썬으로 **72.7 ms** — 53% 감소 |
| **C-3** | `ps` / `mktime` 의 `TZ` 의존으로 수 시간 스큐 | **메커니즘 기각 / 조치는 채택** | 동일 호출 내에서 둘은 **모든 TZ 에서 정확히 일치**한다(4종 실측, epoch 동일). 다만 `TZ=UTC` 고정은 별개 이유로 이득이 있어 채택 |
| **신규** | — | **차단(Blocker)** | **`reconcile.sh` 쓰기 경로가 drift-C 진입 시 `NameError` 로 죽는다.** HEAD 에서 rc=1. C-1 이 지키려던 그 경로가 이미 죽어 있었다 |

추가 변경은 4건(F5–F8)이고, Rev.1 의 F1–F4 는 유지된다.

| | 대상 | 내용 |
|---|---|---|
| **F5** | `reconcile.sh` | `lib_sh` 무조건 바인딩 — drift-C 쓰기 경로 `NameError` 수정 (**차단**) |
| **F6** | `lib.sh::verify_session_uuid` | 트랜스크립트 하한을 `discover` 모드로 한정 (agy 제언 1) |
| **F7** | `lib.sh` 시프트 `ls` | 단일 파이썬 프로세스 + `ps` 일괄 조회 (agy 제언 2) |
| **F8** | `lib.sh` 시프트 `ls` | `TZ=UTC` + `LC_ALL=C` 고정 (agy 제언 3) |

---

## 1. C-1 — 재현되지 않는다. 다만 그 자리에 다른 것이 있었다

### 1.1 주장의 3단계는 일어나지 않는다

agy 는 "`resume_session.sh` 가 신규 herdr 세션을 만들고 그 `shell_pid` 시작시각($T_{resume}$)이 `herdr_session_epoch` 가 된다"고 했다. 이것이 성립해야 나머지 논증이 따라온다. **실측했다** — 원래 epoch 를 심어 둔 행에 대해 `update_yaml_resumed.sh` 를 실행하고 값을 다시 읽었다.

```
  before resume : herdr_session_epoch = 1786100000
  after  resume : herdr_session_epoch = 1786100000   (now = 1786197485)
  status=running  own_id=aaaaaaaa-...

  -> resume LEAVES the original creation epoch in place.
```

`update_yaml_resumed.sh` 는 `herdr_session_epoch` 를 **기존 행이 있으면 건드리지 않는다.** `NOW_EPOCH` 는 행이 아예 없을 때(`target is None`) 새로 만드는 분기에서만 쓰인다 — 그리고 그 분기는 **HEAD 에서도 이미 `NOW_EPOCH` 를 쓰므로 F1 과 무관하다.**

### 1.2 "즉시 제거(Clear)" 도 일어나지 않는다

검증 실패 시 registry 에서 id 를 지우는 코드를 저장소 전체에서 찾았다. `*_id_own = None` 을 쓰는 곳은 `stop_session.sh`(정상 종료), `create_session.sh`·`reconcile.sh`(신규 행 초기화) 뿐이다. **검증 실패를 이유로 id 를 지우는 경로는 존재하지 않는다.** 실제 결과는 "핀이 되지 않음"이고, 에이전트가 다음에 무엇이든 쓰는 순간 mtime 이 올라가 자동 복구된다.

### 1.3 사용자에게 닿는 경로에서도 재현되지 않는다

가장 중요한 확인이다. **resume 이 대화를 잃는가?** 행 epoch = now, 대화는 2시간 전 이후 미기록, uuid 는 이미 핀된 상태 — agy 시나리오 그대로 만들어 `resolve_session_id.sh` 를 세 트리에서 돌렸다.

```
===== b4base (HEAD) =====   -> resolve_session_id RESOLVED the pinned conversation
===== b4fix  (Rev.1) =====  -> resolve_session_id RESOLVED the pinned conversation
===== rev2   (Rev.2) =====  -> resolve_session_id RESOLVED the pinned conversation
```

세 트리 모두 대화를 되찾는다. **C-1 은 사용자에게 닿지 않는다.**

다만 *왜* 되찾는지는 따져 볼 값어치가 있다. `verify_session_uuid` 를 직접 호출해 보면:

```
  b4base   discover=False  revalidate=False
  b4fix    discover=False  revalidate=False
  rev2     discover=False  revalidate=True
```

Rev.1 에서 **행 기반 검사는 실제로 거부한다.** 그런데도 resume 이 성공하는 이유는 `find_workspace_uuid` 가 그 뒤에 **행 없이(`row=None`) 디스크를 스캔하는 폴백**을 돌리기 때문이다. 행이 없으면 `epoch = 0` 이라 하한이 아예 적용되지 않고, 같은 파일이 그대로 통과한다.

즉 **agy 의 우려는 방향이 맞다** — 행 기반 revalidate 는 정말로 거부한다. 다만 그 거부가 사용자에게 보이지 않는 것은 하한을 우회하는 폴백이 뒤를 받쳐 주기 때문이고, 그 폴백은 동시에 B-4 가 세우려는 가드를 약화시키는 지점이기도 하다. 이 의존을 없애는 것이 **F6** 이다(§2.2).

### 1.4 실제로 있던 것 — drift-C 쓰기 경로가 죽어 있다

C-1 을 재현하려고 `reconcile.sh` 를 돌리다 발견했다. 조건을 통제해 epoch 만 바꿔 가며 측정한 결과다(같은 트리, 같은 낡은 트랜스크립트):

```
  epoch = 999999 (HEAD sentinel)   transcript mtime = now-7200
    dry-run rc=0  drifts=['C-warn']
    write   rc=1  <- NameError: name 'lib_sh' is not defined

  epoch = now    (F1 real epoch)   transcript mtime = now-7200
    dry-run rc=0  drifts=[]
    write   rc=0
```

원인은 `reconcile.sh:331-347` 이다.

```python
try:
    d
except NameError:
    import subprocess
    d = {}
    try:
        lib_sh = os.environ.get('LIB_SH')      # <- 이 분기 안에서만 바인딩된다
        ...
```

`lib_sh` 는 `except NameError:` 블록 안에서만 대입된다. 그런데 **쓰기 경로는 그 블록에 들어가지 않는다** — `atomic_dump_yaml` 이 mutation 을 exec 하기 전에 `d` 를 미리 정의하기 때문이다(`lib.sh` 의 해당 함수 내부). 반면 dry-run 경로(`env_python`)는 `d` 를 정의하지 않으므로 블록에 들어가 `lib_sh` 가 바인딩된다.

결과: **drift-C 가 후보를 하나 찾아 핀 단계(`verify_tui_viewport` 명령 문자열 조립)에 도달하는 순간, 쓰기 모드에서만 `NameError` 로 스윕 전체가 중단된다.** 모니터가 실제로 도는 모드가 정확히 그 모드다.

이 사실이 C-1 논쟁을 재정의한다.

- agy 는 "F1 이 정상 트랜스크립트 채택을 막는다"고 했다. 그런데 **HEAD 에서의 대안은 '정상 채택'이 아니라 '`rc=1` 로 죽음'이었다.**
- 그리고 F1 은 후보를 비워 그 크래시를 **우연히 가린다**(위 표의 두 번째 블록이 `rc=0`). 고장이 사라진 게 아니라 도달하지 못하게 된 것이다.

가려진 결함은 고쳐진 결함이 아니다. **F5 는 차단 항목이다.** 이것 없이는 C-1 을 논할 무대 자체가 성립하지 않는다.

---

## 2. 변경 계획 (Rev.1 F1–F4 + 신규 F5–F8)

Rev.1 의 F1(시프트 `ls` 파생), F2(reconcile 위생), F3(mock), F4(tier3 단언)는 **그대로 유지된다.** 상세는 Rev.1 §2 를 참조하고, 아래는 Rev.2 에서 더해지거나 바뀌는 부분만 적는다.

### F5 — `reconcile.sh`: `lib_sh` 무조건 바인딩 (차단)

`try: d / except NameError:` **바깥**에서 바인딩한다. 기존 분기 내부의 대입은 제거한다.

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

# Bound unconditionally. This used to be assigned only inside the "except
# NameError" branch below, which the write path never enters because
# atomic_dump_yaml predefines `d` -- so drift C's pin raised
# NameError: name 'lib_sh' is not defined and aborted the whole sweep,
# in write mode only.
lib_sh = os.environ.get('LIB_SH')
if not lib_sh:
    _ws_root = os.environ.get('WORKSPACE_ROOT')
    if not _ws_root:
        _ws_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))
    lib_sh = os.path.join(_ws_root, '.agents/skills/lib.sh')

try:
    d
except NameError:
    import subprocess
    d = {}
    try:
        script = f"source '{lib_sh}' && load_state_json"
        ...
```

검증 후 `rc=1 → rc=0`, `NameError` 소멸(§4 B-11).

### F6 — `verify_session_uuid`: 하한을 `discover` 로 한정 (agy 제언 1 채택)

```python
    row = row or {}
    # The floor answers "could this transcript belong to a PREVIOUS incarnation
    # of this session?", which only matters while picking an unknown uuid off
    # disk. In "revalidate" the uuid is one this row already recorded, and a
    # session resumed but not yet written to legitimately has a transcript
    # older than its current process -- applying the floor there discards the
    # very conversation the resume was for.
    epoch = row.get("herdr_session_epoch", 0) if mode == "discover" else 0
```

**agy 의 제언을 채택하되 근거를 바꾼다.** 이 변경은 §1.3 에서 보았듯 **사용자에게 보이는 동작을 바꾸지 않는다** — 지금은 하한 우회 폴백이 뒤를 받치고 있기 때문이다. 채택하는 이유는 두 가지다.

1. `revalidate` 라는 이름이 약속하는 계약을 코드가 지키게 된다. 이미 이 행이 기록한 id 를 재확인하는 자리이고, `sessionId`·`cwd` 대조는 그대로 남는다.
2. resume 의 성공이 **하한을 우회하는 폴백에 의존하지 않게 된다.** 그 폴백은 B-4 가드를 약화시키는 지점이기도 해서 언젠가 조여야 하는데(§6.1), F6 없이 조이면 resume 이 조용히 깨진다.

`discover` 에는 하한이 그대로 남으므로 B-4 의 보호는 유지된다(§4 B-9, B-12 가 양쪽을 함께 고정한다).

**하지 않은 것**: agy 는 `last_visible_status` / `resumed` 필드를 검증 조건에 넣자고도 제안했다. 넣지 않았다. 그 필드들은 문자열 서술이고 여러 곳에서 자유롭게 덮어써지므로 가드의 입력으로 삼으면 결합이 늘고 판정이 취약해진다. `mode` 는 이미 호출자가 의도를 선언하는 정식 파라미터이고, 필요한 구분을 정확히 담고 있다.

### F7 — 시프트 `ls`: 단일 파이썬 + `ps` 일괄 조회 (agy 제언 2 채택)

Rev.1 은 bash `while read` 루프 안에서 에이전트마다 `herdr` / `python3` / `ps` / `python3` 를 포크했다. Rev.2 는 **파이썬 한 프로세스**가 `_real_herdr` 의 세션 인자 처리를 재현해 `pane process-info` 를 직접 호출하고, 모든 pid 를 **`ps` 한 번**으로 조회한다.

포크 수: `4N + 3` → **`N + 3`**.

실측(라이브 에이전트 3개, 20회 평균):

```
  HEAD (constant, no work)                 42.6 ms/call
  Rev.1 F1 (shell loop, ~4N+3 forks)      156.0 ms/call
  Rev.2 F7 (single python, ~N+3)           72.7 ms/call
```

heartbeat 주기는 `RECONCILE_POLL_INTERVAL` 기본 **15초**다. Rev.1 은 주기당 약 113 ms 를 더 쓰고, Rev.2 는 30 ms 로 줄인다. 출력은 Rev.1 과 문자 단위로 동일하다(§4 B-1..B-6 이 rev2 에서 그대로 통과).

**함께 넣는 에러 경로 강화 — 이건 범위 확장이므로 명시한다.** `agent list` 를 파싱하지 못했을 때 Rev.1 은 `sys.exit(0)` 로 조용히 빈 출력을 냈다. `reconcile.sh:382-388` 은 **rc=0 + 빈 stdout 을 "herdr 확인됨, 세션 0개"로 읽고**, 그러면 YAML 의 살아 있는 행들이 drift A 로 terminated 처리된다. 파싱 실패는 "세션이 없다"가 아니라 "모르겠다"이므로 **nonzero 로 종료**해야 reconcile 이 `herdr_confirmed=False` 쪽으로 빠진다. 한 줄짜리 변경이지만 결과 차이가 커서 넣었고, B-13 으로 고정했다.

전문은 §7.1.

### F8 — `TZ=UTC` + `LC_ALL=C` (agy 제언 3 의 조치만 채택)

**제시된 메커니즘은 성립하지 않는다.** agy 는 `ps` 와 `mktime` 이 서로 다른 `TZ` 를 봐서 수 시간 스큐가 난다고 했다. 둘은 같은 호출 안에서 같은 환경을 상속하므로 다를 수 없다. 실측:

```
  TZ=UTC              ps='Fri Aug  7 22:34:49 2026'  mktime=1786142089
  TZ=America/New_York ps='Fri Aug  7 18:34:49 2026'  mktime=1786142089
  TZ=Asia/Seoul       ps='Sat Aug  8 07:34:49 2026'  mktime=1786142089
  TZ=Not/AZone        ps='Fri Aug  7 22:34:49 2026'  mktime=1786142089
```

`ps` 는 확실히 `TZ` 를 따르고(시각 표기가 실제로 바뀐다), 파이썬도 같은 `TZ` 로 되읽는다. **네 경우 모두 epoch 가 완전히 동일하다.** 스큐는 없다.

그럼에도 `TZ=UTC` 는 채택한다. 이유가 다르다 — Rev.1 §3.4 에서 내가 남겨 둔 **DST 모호성**을 없애기 때문이다. `mktime` 은 로컬시각을 해석하므로 연 1회 중복되는 1시간 구간에서 최대 1시간 흔들린다. 양쪽을 UTC 로 고정하면 그 구간 자체가 사라진다. 공짜이고 엄격히 낫다.

---

## 3. 부작용 재검토

### 3.1 F6 이 B-4 를 되돌리지 않는가

되돌리지 않는다. `discover` — 디스크에서 **모르는** uuid 를 고르는 자리 — 에는 하한이 그대로 있다. Rev.1 의 피해 재현 테스트(B-9)는 `discover` 를 쓰므로 rev2 에서도 그대로 초록이고, B-12 가 `discover=False` 와 `revalidate=True` 를 **같은 입력으로 동시에** 단언해 한쪽만 느슨해지는 변경을 막는다.

### 3.2 F5 가 감추던 크래시를 드러내지 않는가

드러낸다 — 그리고 그게 목적이다. F5 이후 drift-C 는 핀 단계까지 정상 진행하고, 뷰포트가 맞지 않으면 `C-warn` 을 남긴다(§1.4 의 dry-run 과 동일한 거동). 쓰기 경로가 dry-run 과 같은 판정을 내리는 상태가 정상이다.

### 3.3 shellcheck

Rev.1 §3.3 과 동일하다. 시프트 본문은 `cat <<'EOF'` 인용 heredoc 안의 문자열이라 shellcheck 이 분석하지 않는다 — F7 의 회귀 위험은 없고, 동시에 lint 사각지대다. `bash -n` 은 두 파일 모두 통과. 로컬에 shellcheck 이 없어 **CI 잡 확인은 Creator 몫**이다.

### 3.4 F7 의 파이썬은 홑따옴표로 감싼다

Rev.1 은 `python3 -c "..."`(큰따옴표) 였으나 Rev.2 는 `python3 -c '...'` 다. 코드가 길어져 큰따옴표 이스케이프가 늘면 읽기 어려워지기 때문이다. 대신 **파이썬 본문에 `$` 를 쓰지 않는다**는 제약이 생긴다(홑따옴표 안이라 bash 확장은 없지만, 향후 수정자가 큰따옴표로 되돌릴 때 사고가 나기 쉬운 지점이다). 필요한 값은 전부 환경변수(`MAM_LS_*`)로 넘긴다.

---

## 4. 테스트 계획

`tests/test_b4_session_created.py` — Rev.1 15건 + Rev.2 6건 = **21건**.

| ID | 검증 대상 | 유래 |
|---|---|---|
| B-1..B-6 | epoch 가 실제 시각이고, pane 루트 프로세스에서 오며, 폴백이 보수적 | Rev.1 |
| B-7 ×2 | sentinel 이 워크스페이스 상태에 남지 않음 | Rev.1 |
| B-8 ×5 | `ls` 필드 위생 처리 | Rev.1 |
| B-9 | 가드가 낡은 트랜스크립트를 거부하고 살아 있는 것은 받아들임 | Rev.1 |
| B-10 | 등록 폴백이 `0` 으로 떨어지지 않음 | Rev.1 |
| **B-11** | **쓰기 경로가 drift-C 진입 시 죽지 않음** | **§1.4 (신규)** |
| **B-12** | **하한이 `discover` 에만 적용됨** — 같은 입력으로 양쪽 동시 단언 | **C-1 / F6** |
| **B-13** | **파싱 불가한 agent list 가 rc=0 이 아님** | **F7 강화** |
| **B-14 ×3** | **caller `TZ` 가 epoch 를 움직이지 않음** | **C-3 / F8** |

### 4.1 트리별 결과

| 트리 | 결과 | 실패 항목 |
|---|---|---|
| `b4base` (clean HEAD) | **2 / 21** | — |
| `b4fix` (Rev.1) | **18 / 21** | B-11, B-12, B-13 |
| `rev2` (Rev.2) | **21 / 21** | — |

Rev.1 에서 정확히 F5·F6·F7강화 세 항목만 실패한다. 즉 신규 3건이 각각 자기 변경을 변별한다.

### 4.2 공허하지 않음의 근거

- **변이 2종**(Rev.1 에서 수행, rev2 에서도 유효): `except ValueError` 제거 → B-8 2건 실패. 시프트가 `foreground_processes[0]` 사용 → B-3·B-4 실패.
- HEAD 에서 통과하는 2건은 **B-4 와 B-9 로 의도된 대조군**이다. B-4 는 HEAD 에서 공허하게 통과하는 함정 탐지기이고, B-9 는 "sentinel 이면 가드가 꺼지고 실제 시각이면 켜진다"는 대조를 고정한다.

### 4.3 B-14 는 수정 탐지기가 아니다 — 명시한다

B-14 는 **Rev.1 에서도 통과한다.** §2 F8 에서 실측한 대로 스큐가 애초에 없기 때문이다. F8 이 없애는 것은 DST 모호성이고, 그건 이 테스트가 도달할 수 없는 영역이다. B-14 를 남기는 이유는 회귀 방지다 — 나중에 누가 naive `datetime` 으로 갈아타거나 `LC_ALL` 을 빼면 즉시 깨지도록. **F8 의 효과를 측정하는 테스트가 아니라는 점을 표로 감추지 않고 여기 적어 둔다.**

### 4.4 회귀

```
b4base (clean HEAD) : 128 passed
b4fix  (Rev.1)      : 143 passed
rev2   (Rev.2)      : §8.1
```

---

## 5. 적용 순서와 리스크

### 순서

1. **F5** — 단독 선행. 차단 항목이고, 다른 변경과 무관하게 그 자체로 결함 수정이다. 이후 단계의 관측이 가능해진다.
2. **F1** → **F7/F8** — F7 은 F1 의 `ls` 핸들러를 다시 쓰는 것이므로 한 커밋으로 묶어도 좋다. 나누려면 반드시 F1 먼저.
3. **F2** — F1 과 독립.
4. **F6** — 단독. `discover`/`revalidate` 양쪽을 B-12 로 확인.
5. **F3** → **F4** — F3 이 선행(F4 의 단언이 mock 의 실제 시각에 의존).

### 리스크

| | 내용 | 완화 |
|---|---|---|
| R-1 | `ps` 부재/제한 환경 | `now` 폴백. B-5/B-6 |
| R-2 | `lstart` 로케일 의존 | `LC_ALL=C`. B-14 |
| R-3 | 시프트 본문 shellcheck 사각지대 | `bash -n` 통과. CI 확인은 Creator (§3.3) |
| R-4 | F6 이 `discover` 까지 느슨하게 만들 위험 | B-12 가 같은 입력으로 양쪽 동시 단언 |
| R-5 | F5 이후 drift-C 가 실제로 핀을 쓰기 시작 | 의도된 결과. §3.2. 첫 배포 후 `C-warn` 빈도 관찰 권고 |

### 5.1 채택하지 않은 것

- **`last_visible_status` / `resumed` 를 가드 입력으로**(agy 제언 1 의 대안) — §2.2 후단 참조.
- **`herdr_session_epoch_source` 출처 필드** — Rev.1 §5.1 의 판단을 유지한다. F1 이 있으면 reconcile 폴백은 사실상 도달 불가 경로이고, 새 YAML 키는 스키마 문서·상태 병합·테스트로 파급된다.
- **오염된 기존 행 마이그레이션** — 이 워크스페이스 실측 결과 `epoch < 1000000000` 인 행은 **0건**이다(Rev.1 §5.1). 관측되지 않은 상태를 위한 마이그레이션은 넣지 않는다.

---

## 6. 범위 밖 — 별건 권고

Rev.1 §6 의 세 항목(`pane_pid` 가 caffeinate 의 pid 를 반환 / `reconcile.sh:374` 디버그 출력 / `IMPROVEMENTS.md` B-4 서술 정정)은 그대로 유효하다. Rev.2 에서 하나 추가한다.

### 6.1 `find_workspace_uuid` 의 무행(row-less) 디스크 스캔 폴백

§1.3 에서 드러난 구조다. 행 기반 검사가 거부해도 그 뒤의 폴백이 `verify_session_uuid(ws, agent, cand)` 를 **행 없이** 호출하므로 `epoch = 0` 이 되어 하한이 적용되지 않고, 같은 파일이 그대로 통과한다.

- 지금은 이것이 resume 을 살리고 있다(그래서 C-1 이 사용자에게 닿지 않는다).
- 동시에 B-4 가 세우려는 하한을 이 경로에서 무력화한다.

F6 을 넣으면 resume 은 더 이상 이 폴백에 의존하지 않으므로, 폴백을 조일 수 있는 상태가 된다. **다만 조이는 작업 자체는 이 브리프의 범위가 아니고, 조이면 무엇이 깨지는지 별도 측정이 필요하다.** F6 은 그 선행 조건을 만들어 둘 뿐이다.

---

## 7. 부록 — 코드 전문

### 7.1 F1 + F7 + F8 — `lib.sh` 시프트 `ls` 핸들러 최종형

`lib.sh:545` 의 `ls)` 분기 전체를 다음으로 교체한다. (Rev.1 §7.1 을 대체한다.)

```bash
  ls)
    format=""
    while [ $# -gt 0 ]; do
      case "$1" in
        -F)
          if [ $# -lt 2 ]; then
            echo "Error: -F requires a value" >&2
            exit 1
          fi
          format="$2"
          shift 2
          ;;
        *) shift ;;
      esac
    done
    # herdr has no session-creation timestamp: `agent list`, `agent get`,
    # `pane get` and `api snapshot` all lack one. Derive it from the OS as the
    # start time of the pane's ROOT process (`shell_pid`).
    #
    # It must NOT come from `foreground_processes[0]`: under MAM that slot
    # holds the `caffeinate -i -t 300` keep-awake wrapper, which respawns every
    # five minutes. Its start time creeps forward, so a session idle for longer
    # than one caffeinate cycle would look "created after" its own transcript
    # and be discarded by the stale-transcript guard in verify_session_uuid.
    #
    # This runs as ONE python process that re-implements `_real_herdr` and
    # batches a single `ps` over every pid, rather than forking python and ps
    # once per agent inside a shell loop: reconcile calls this on a 15s
    # heartbeat, so per-agent forking shows up as a standing cost.
    #
    # TZ=UTC pins both `ps` (which prints lstart in the caller's zone) and
    # `mktime` (which reads it back in the caller's zone) to the same zone.
    # They already agree at any single TZ, so this is not about skew between
    # them -- it removes the once-a-year DST ambiguity in local-time mktime.
    # LC_ALL=C pins lstart's field names, which are locale-dependent.
    _sess="${HERDR_SESSION_NAME:-}"
    if [ "$_sess" = "default" ]; then
      _sess=""
    fi
    _real_herdr agent list 2>/dev/null | \
      MAM_LS_FORMAT="$format" MAM_REAL_HERDR="$REAL_HERDR" MAM_LS_SESSION="$_sess" \
      TZ=UTC LC_ALL=C python3 -c '
import json, os, subprocess, sys, time

fmt = os.environ.get("MAM_LS_FORMAT", "")
real = os.environ.get("MAM_REAL_HERDR") or "herdr"
sess = os.environ.get("MAM_LS_SESSION") or ""
base = [real] + (["--session", sess] if sess else [])

raw = sys.stdin.read()
try:
    agents = json.loads(raw).get("result", {}).get("agents", []) or []
except Exception:
    # Unparseable means "we do not know", not "there are no sessions".
    # Exiting nonzero lets reconcile fall into its herdr_confirmed=False path
    # instead of reading empty stdout as a confirmed zero and terminating
    # every live row it has on file.
    sys.exit(1)

rows = []
for a in agents:
    name = a.get("name") or a.get("agent") or "unknown"
    pane = a.get("pane_id") or ""
    pid = None
    if pane:
        try:
            out = subprocess.run(base + ["pane", "process-info", "--pane", pane],
                                 capture_output=True, text=True, timeout=10).stdout
            pi = json.loads(out).get("result", {}).get("process_info", {})
            p = pi.get("shell_pid") or pi.get("foreground_process_group_id")
            if isinstance(p, int) and p > 0:
                pid = p
        except Exception:
            pass
    rows.append((name, pid))

starts = {}
pids = sorted({p for _, p in rows if p})
if pids:
    try:
        out = subprocess.run(["ps", "-o", "pid=,lstart=", "-p", ",".join(str(p) for p in pids)],
                             capture_output=True, text=True, timeout=10).stdout
        for line in out.splitlines():
            line = line.strip()
            if not line:
                continue
            head, _, rest = line.partition(" ")
            try:
                starts[int(head)] = int(time.mktime(time.strptime(rest.strip(), "%a %b %d %H:%M:%S %Y")))
            except Exception:
                pass
    except Exception:
        pass

# Degrade to now, never to 0 or a sentinel. The consumer guard is
#   if epoch and mtime(transcript) < epoch: reject
# so an over-estimate only makes it stricter, while an under-estimate
# (0 is falsy; 999999 is 1970-01-12 and below every real mtime) switches
# the guard off outright -- which is the B-4 defect.
now = int(time.time())
for name, pid in rows:
    if fmt == "#{session_name}":
        print(name)
    else:
        print(name + "|" + str(starts.get(pid) or now))
'
    ;;
```

### 7.2 F2 — `reconcile.sh` 위생 처리

Rev.1 §7.2 그대로다(바닥값 상수 `MAM_EPOCH_FLOOR`, 국소 `except ValueError`, 등록 폴백 `int(time.time())`). 변경 없음.

### 7.3 F5 — `reconcile.sh` `lib_sh` 바인딩

§2.1 참조.

### 7.4 F6 — `verify_session_uuid`

§2.2 참조.

### 7.5 F3 / F4 — 테스트 인프라

Rev.1 §7.3 / §7.4 그대로다. 변경 없음.

### 7.6 테스트 파일

```
scratchpad/rev2/tests/test_b4_session_created.py      (21건, 최종형)
```

재현용 독립 프로브:

- `scratchpad/probe_b4.sh <tree>` — B-1..B-6 을 6줄로 출력
- `scratchpad/probe_harm.sh <tree>` — B-4 피해 재현
- `scratchpad/probe_resume_epoch.sh <tree>` — §1.1 (resume 이 epoch 를 덮는가)
- `scratchpad/probe_c1c.sh <tree>` — §1.3 (resume 이 대화를 되찾는가)
- `scratchpad/probe_f6.py` — §1.3 (discover / revalidate 판정표)

---

## 8. 검증 로그

```
tests/test_b4_session_created.py
  b4base (clean HEAD)   :  2 / 21
  b4fix  (Rev.1)        : 18 / 21     B-11, B-12, B-13 실패
  rev2   (Rev.2)        : 21 / 21

변이
  b4mut  (except ValueError 제거)      B-8 ×2 실패
  b4mut2 (fg[0] 사용)                  B-3, B-4 실패

성능 (라이브 에이전트 3개, 20회 평균)
  HEAD                42.6 ms/call
  Rev.1              156.0 ms/call
  Rev.2               72.7 ms/call      (-53%)

bash -n lib.sh / reconcile.sh          OK
shellcheck                             로컬 미설치 — CI 확인 필요
```

### 8.1 회귀 결과

```
b4base (clean HEAD)  : 128 passed in 504.37s
b4fix  (Rev.1)       : 143 passed in 467.19s     (128 + 신규 15)
rev2   (Rev.2)       : 149 passed in 623.78s     (128 + 신규 21)
```

기존 128건 전부 유지, 실패·에러 0건. F5(`lib_sh` 바인딩)와 F6(모드 한정 하한)이 기존 reconcile·resume 테스트를 건드리지 않음을 확인했다.

라이브 시스템에서 Rev.1 과 Rev.2 의 `ls` 출력이 두 포맷 모두 바이트 단위로 동일함도 확인했다 — F7 은 성능 변경이지 동작 변경이 아니다.

```
  [Rev.1] ...-creator-cline|1786194117   [Rev.2] ...-creator-cline|1786194117
          ...-creator-agy|1786194156             ...-creator-agy|1786194156
          ...-creator-claude|1786194098          ...-creator-claude|1786194098
```

---

## 9. Challenger 회신 (agy)

세 지적 모두 코드를 정확히 읽은 데서 나왔고, 두 건은 계획서를 실제로 개선했다. 다만 **셋 다 결론까지 가는 중간 단계가 실측과 어긋난다.**

- **C-1** — `resume_session.sh` 는 `herdr_session_epoch` 를 덮지 않는다(기존 행일 때). 검증 실패로 id 를 지우는 코드도 없다. 그리고 사용자 경로에서 재현되지 않는다 — 세 트리 모두 대화를 되찾는다. 다만 **행 기반 revalidate 가 실제로 거부한다**는 관찰은 맞았고, 그것이 폴백에 가려져 있다는 사실을 드러낸 것이 이번 개정의 수확이다(F6, §6.1).
- **C-2** — 맞다. 실측 156 ms → 72.7 ms. 제안한 방향 그대로 채택했다.
- **C-3** — `ps` 와 `mktime` 은 같은 호출 안에서 같은 `TZ` 를 보므로 스큐가 날 수 없다(4종 TZ 실측, epoch 전부 동일). 조치(`TZ=UTC`)는 **다른 이유로** 채택했다 — Rev.1 이 남겨 둔 DST 모호성이 사라진다.

그리고 이 검토가 아니었다면 찾지 못했을 것: **`reconcile.sh` 의 쓰기 경로는 drift-C 가 후보를 찾는 순간 `NameError` 로 죽고 있었다.** C-1 이 지키려던 그 경로다. F1 은 후보를 비워 그 크래시를 가리기까지 했다. 이의제기가 없었으면 가려진 채로 넘어갔을 결함이다.

[AGREEMENT: REACHED]

---

## 10. 산출물과 경계

- **작성한 것**: 이 계획서, `scratchpad/` 의 검증 트리(`b4base`/`b4fix`/`rev2`/`b4mut`/`b4mut2`)·프로브 5종·테스트 파일.
- **저장소에 가한 변경**: 없음. 모든 프로토타이핑은 `git archive HEAD | tar -x` 로 뜬 별도 트리에서 수행했다.
- **Creator 가 할 일**: §5 순서대로 F5 → F1 → F7/F8 → F2 → F6 → F3 → F4 적용, `tests/test_b4_session_created.py` 추가, CI shellcheck 잡 확인.
- **권고(별건)**: Rev.1 §6 의 3건 + §6.1 무행 폴백.
