okstra 0.89.0 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ # SubagentStop 기반 worker pane 완료-즉시 회수 + 내성 fallback 설계
2
+
3
+ - 작성일: 2026-06-18
4
+ - 상태: 설계(승인 대기)
5
+ - 관련: [_common-contract.md](../../../prompts/profiles/_common-contract.md) phase-start cleanup, [okstra-trace-cleanup.sh](../../../scripts/okstra-trace-cleanup.sh), [tmux.py](../../../scripts/okstra_ctl/tmux.py), [settings.template.json](../../../templates/reports/settings.template.json)
6
+
7
+ ## 1. 배경 — 근거로 확정된 실제 원인
8
+
9
+ `jobs:uploadfont:dev-9047` 의 implementation-planning run 에서 13번째 worker dispatch(antigravity gap-verify)가 하니스 에러로 실패했다:
10
+
11
+ > `Failed to create teammate pane: no space for new pane — no room for another tmux split`
12
+
13
+ 이 run 의 lead 가 `state/session-work-summary.md` 5절에 자가 분석을 남겼으나, 그 두 전제가 디스크 데이터와 어긋났다. 코드/데이터로 재검증한 결과는 다음과 같다.
14
+
15
+ ### 1.1 정정된 사실
16
+
17
+ - **에러 주체는 okstra 가 아니라 Claude Code 하니스다.** okstra 의 `split_worker_pane` 은 실패해도 조용히 degrade 한다([dispatch_core.py](../../../scripts/okstra_ctl/dispatch_core.py) `_start_tmux_or_degrade`). 위 메시지는 하니스의 teammate pane 생성이 낸 것이다.
18
+ - **"lead-pane.id 빈 값 → lead 가 tmux 밖"(자가분석 주장 4)은 사실과 다르다.** 그 run 의 `state/lead-pane.id` = `%319` 로, lead 는 tmux 안에 있었고 기록도 정상이었다. phase-start cleanup 의 lead-in-tmux 게이트는 통과 가능한 상태였다.
19
+ - **"no-team 이라 shutdown_request 가 skip 되어 pane 누적"(주장 3)은 인과가 틀렸다.** `shutdown_request` 는 FleetView roster 정리이지 tmux pane 회수가 아니다([_common-contract.md:42](../../../prompts/profiles/_common-contract.md) — dismissed teammate 는 idle pill 로 남는다고 명시). tmux pane 회수는 전적으로 `okstra-trace-cleanup.sh` 의 일이고 no-team 여부와 무관하다.
20
+
21
+ ### 1.2 진짜 원인
22
+
23
+ lead 가 tmux 안이었으므로 pane 회수((a) `okstra-trace-cleanup.sh`)는 돌 수 있었는데 누적을 못 막았다. 두 구조적 사실 때문이다.
24
+
25
+ 1. **cleanup 미실행 정황.** phase-start cleanup 은 prompt 계약일 뿐 코드 강제가 아니며, 그 run 의 Phase 진행 내역에 실행 기록이 없다.
26
+ 2. **okstra 는 하니스가 만든 teammate pane 에 태그를 못 단다.** `tag_pane` 은 okstra 가 직접 split 한 pane 에만 붙는다([tmux.py](../../../scripts/okstra_ctl/tmux.py) `split_worker_pane` 내부). 하니스 teammate pane 의 식별 수단은 title 매칭뿐이고, 그건 하니스가 pane title 을 어떻게 다는지에 의존해 불확실하다.
27
+
28
+ 그 run 의 pane 구성: 하니스 teammate pane 13개 + codex/antigravity wrapper 가 만든 trace pane 7개 = 한 window 에 20개 누적 → 분할 한도 초과.
29
+
30
+ ## 2. 목표 / 비목표
31
+
32
+ ### 목표
33
+ - 완료된 worker 의 pane 을 **완료 즉시** 회수해 한 window 의 누적이 분할 한도에 닿지 않게 한다(예방).
34
+ - 그럼에도 한도에 닿으면 run 이 멈추지 않고 자동으로 in-process 대체로 진행한다(내성).
35
+
36
+ ### 비목표
37
+ - 하니스가 teammate pane 을 만드는 행위 자체를 막거나 바꾸지 않는다(okstra 통제 밖).
38
+ - 외부 CLI 워커(codex/antigravity)를 제거해 cross-verification 다양성을 훼손하지 않는다.
39
+ - FleetView roster 표시(idle pill) 정리는 본 설계 범위 밖(별개 메커니즘).
40
+
41
+ ## 3. 제약 (설계가 마주한 벽)
42
+
43
+ | pane 종류 | 누가 만드나 | okstra 통제력 |
44
+ |---|---|---|
45
+ | 외부 CLI trace pane (`<cli>-<role>-<pid>-tail`) | codex/antigravity wrapper = **okstra 코드** | 높음 — `@okstra_trace_run` 태그로 식별·회수 |
46
+ | teammate pane (`*-worker` title) | **하니스** (Agent 도구 / Teams Task) | 낮음 — 태그 불가, title 매칭에만 의존 |
47
+
48
+ → 확실히 회수 가능한 것은 **trace pane**(태그). teammate pane 은 title 회수가 되는지 **실측 게이트**(§6)로 확정해야 하며, 안 되면 내성(④)으로 백업한다.
49
+
50
+ ## 4. 설계
51
+
52
+ ### ① 훅 포인트: `SubagentStop`(+`TaskCompleted`)
53
+
54
+ PreToolUse(Agent)는 병렬 dispatch 시 Agent 호출마다 N회 발화하므로, dispatch 직전 일괄 cleanup 은 **같은 batch 의 진행 중 형제 worker pane 을 죽인다**(claude-code-guide 가 공식 hooks 문서로 확인). 따라서 채택하지 않는다.
55
+
56
+ 대신 **완료 이벤트**에 건다 — 완료된 그 worker 만 정리하므로 진행 중 worker 는 건드리지 않고, 누적이 batch 경계까지 쌓이지 않는다.
57
+
58
+ - no-team(Agent background) 경로 → **`SubagentStop`** (입력: `session_id`, `agent_id`, `agent_type`, `agent_transcript_path`).
59
+ - Teams(`TaskCreate`) 경로 → **`TaskCompleted`** (입력: `task_id`, `teammate_name`, `team_name`, `session_id`).
60
+
61
+ 두 훅 모두 [settings.template.json](../../../templates/reports/settings.template.json) 의 `hooks` 에 신규 배선한다(기존 SessionEnd `--reap`, PreToolUse coding-preflight 는 유지). 둘 다 부수효과 전용 — 스크립트는 항상 `exit 0`, 도구/세션 진행을 차단하지 않는다.
62
+
63
+ ### ② cleanup 에 "완료된 worker만" 안전 모드 추가
64
+
65
+ 현재 `okstra-trace-cleanup.sh --run-dir <RUN_DIR>` 은 그 run 의 태그/title pane 을 **진행 중 여부와 무관하게** 모두 닫는다. 완료 이벤트 훅에서 안전하게 쓰려면, **결과 파일이 이미 존재하는(=완료된) worker 의 pane 만** 닫는 모드가 필요하다.
66
+
67
+ 새 모드(가칭) `--reclaim-completed --run-dir <RUN_DIR>`:
68
+ - §6 실측 결과에 따라 닫는 대상이 정해진다.
69
+ - 항상 닫음(확실): `@okstra_trace_run == RUN_DIR` 태그 trace pane 중, 대응 worker 의 `result_path` 가 존재하는 것.
70
+ - 조건부(실측 통과 시): lead 세션 안에서 title 이 `*-worker` allowlist 에 맞고 대응 worker 의 `result_path` 가 존재하는 teammate pane.
71
+ - 진행 중(result 미존재) worker pane 은 **절대 닫지 않는다** — 이 가드가 본 모드의 핵심.
72
+
73
+ trace pane → worker 매핑은 pane title(`<cli>-<role>-<pid>-tail`)의 role 과 run-dir 의 worker 산출물 경로(result_path) 규약으로 해석한다. 정확한 매핑 규약은 구현 plan 에서 `dispatch_core` 의 `status_path_for_prompt` / `result_path` 명명과 대조해 확정한다.
74
+
75
+ ### ③ session ↔ run-dir 매핑 기록 (훅이 run-dir 을 알기 위함)
76
+
77
+ `SubagentStop`/`TaskCompleted` 입력은 `session_id`(lead 세션)를 주지만, 훅의 `cwd` 가 run-dir 과 일치한다는 보장이 없고 run-dir 전용 env 도 없다(공식 문서 확인). 그리고 **현재 `active.jsonl` row 에는 `leadSessionId` 가 없다**([index.py](../../../scripts/okstra_ctl/index.py) `reserve_active` row — `runId/taskKey/runDirRel/...` 만).
78
+
79
+ 따라서 lead 가 run 시작 시 자기 `session_id ↔ RUN_DIR` 매핑을 알려진 위치에 기록한다. 두 후보:
80
+
81
+ - **(권장)** `~/.okstra/sessions/<session_id>.run` 단일 파일에 현재 RUN_DIR(절대경로)을 기록. run 시작 시 갱신, SessionEnd `--reap` 가 정리. 한 세션이 여러 run 을 순차 구동하면 **가장 최근 run-dir** 로 덮어쓴다(완료 이벤트는 항상 최근 run 소속이므로 안전).
82
+ - (대안) `active.jsonl` row 에 `leadSessionId` 필드를 추가. 다만 한 세션 = 여러 active row 일 때 최근 row 선택 로직이 필요하고 중앙 락 경합을 늘린다.
83
+
84
+ 훅 스크립트는 stdin JSON 의 `session_id` 로 이 파일을 읽어 RUN_DIR 을 얻은 뒤 ②를 호출한다. 매핑이 없으면(파일 부재) silent no-op.
85
+
86
+ ### ④ 내성 — lead 계약의 pane-실패 fallback
87
+
88
+ 하니스의 teammate pane 생성은 okstra Python 이 관여하지 않으므로 코드로 강제할 수 없다. lead 행동 계약([_common-contract.md](../../../prompts/profiles/_common-contract.md) convergence/dispatch 절)에 명시한다:
89
+
90
+ - worker dispatch 가 `no room for another tmux split`(또는 동등한 pane 생성 실패)로 거부되면, 그 worker 를 **`run_in_background` 없이 in-process** 로 재시도하거나, 외부 CLI 워커였다면 in-process claude 분석으로 대체하고 그 사실(provider 불가)을 run 기록에 남긴다.
91
+ - 이미 `session-work-summary.md` 의 완화방안에 등장한 동작을 계약으로 승격하는 것이며, 코드 강제가 아닌 prompt 지시임을 문서에 명시(declaration ≠ enforcement 규칙).
92
+
93
+ ## 5. 데이터 흐름
94
+
95
+ ```
96
+ run 시작 (lead)
97
+ └─ ~/.okstra/sessions/<session_id>.run := RUN_DIR (③)
98
+
99
+ worker N 완료
100
+ └─ 하니스가 SubagentStop / TaskCompleted 발화
101
+ └─ 훅 command: okstra-subagent-reclaim.sh (stdin: session_id, agent_*)
102
+ ├─ session_id → RUN_DIR (sessions/<id>.run 읽기, 없으면 no-op)
103
+ └─ okstra-trace-cleanup.sh --reclaim-completed --run-dir RUN_DIR (②)
104
+ ├─ result_path 존재하는 worker 의 trace pane(태그) kill [확실]
105
+ └─ (실측 통과 시) result_path 존재하는 worker 의 teammate pane(title) kill [조건부]
106
+ ※ 진행 중(result 미존재) worker pane 은 보존
107
+
108
+ dispatch 가 pane 실패로 거부 (lead)
109
+ └─ in-process 대체로 진행 + 기록 (④)
110
+
111
+ session 종료
112
+ └─ SessionEnd: okstra-trace-cleanup.sh --reap (기존 유지)
113
+ └─ sessions/<session_id>.run 정리
114
+ ```
115
+
116
+ ## 6. 실측 게이트 (구현 첫 단계, 코드 작성 전)
117
+
118
+ 문서로 확인 불가한 단 하나의 가정 — **하니스가 teammate pane title 을 `*-worker` allowlist 에 맞게 다는가.**
119
+
120
+ 검증 절차: tmux 안에서 okstra worker(claude-worker) 1개를 띄운 직후 `tmux list-panes -a -F '#{pane_id}\t#{pane_title}'` 로 그 pane 의 실제 title 을 확인한다.
121
+ - 매칭됨 → ② 의 teammate pane 회수(조건부)를 활성화한다.
122
+ - 매칭 안 됨 → ② 는 trace pane 만 회수하고, teammate pane 누적은 ④ 내성에 전적으로 의존한다(설계 문서·계약에 그 한계를 명시).
123
+
124
+ 이 결과가 ②의 범위를 확정하므로, 구현 plan 의 첫 task 로 둔다.
125
+
126
+ > **구현 후 결론 (2026-06-18).** 실측과 무관하게 **hook 경로의 teammate pane 회수는 구조적으로 불가**로 확정됐다. hook 은 완료 이벤트마다 worker 하나씩 발화하는데 teammate pane 은 generic title(`*claude-worker*` 등)로만 식별되고 per-worker 태그가 없어([okstra-trace-cleanup.sh](../../../scripts/okstra-trace-cleanup.sh) `collect_okstra_panes` 의 title allowlist), 같은 batch 의 진행 중 형제 pane 과 완료 pane 을 구분할 수 없다 → title kill 시 진행 중 worker 를 죽일 위험. 그래서 `--reclaim-completed` 는 title 스캔을 건너뛴다(`RECLAIM -eq 0` 가드). teammate pane 을 안전하게 회수할 수 있는 유일한 지점은 **batch 경계**(직전 batch 의 모든 worker 가 완료된 시점)이며, 그것이 lead 의 phase-start cleanup(`--run-dir`, RECLAIM=0)이다. 위 §6 의 title-매칭 여부는 이 batch-경계 스캔의 유효성에 대한 질문일 뿐 hook 가능성과는 직교한다. 따라서 ②의 teammate 부분은 hook 에 넣지 않고 lead 의 batch-경계 cleanup 에 맡긴다.
127
+
128
+ ## 7. 변경 대상 (구현 plan 에서 구체화)
129
+
130
+ | 파일 | 변경 |
131
+ |---|---|
132
+ | `templates/reports/settings.template.json` | `SubagentStop` + `TaskCompleted` 훅 신규 배선 |
133
+ | `scripts/okstra-subagent-reclaim.sh` (신규) | 훅 진입점 — stdin `session_id` → RUN_DIR → cleanup 호출 |
134
+ | `scripts/okstra-trace-cleanup.sh` | `--reclaim-completed` 모드(완료 worker pane 만) 추가 |
135
+ | `scripts/okstra_ctl/*` (run 시작 경로) | `~/.okstra/sessions/<session_id>.run` 매핑 기록 |
136
+ | `prompts/profiles/_common-contract.md` | ④ 내성 계약 명시(pane-실패 → in-process 대체) |
137
+ | `src/install.mjs` 관련 시드 | 신규 스크립트/훅이 설치 payload 에 포함되는지 확인 |
138
+
139
+ ## 8. 테스트 전략
140
+
141
+ - `okstra-trace-cleanup.sh --reclaim-completed` 단위: result_path 존재/부재로 진행 중 worker pane 보존 vs 완료 pane 회수를 가르는지(태그 fixture 기반, tmux 없는 환경에서 list 모드로 검증).
142
+ - session↔run-dir 매핑 기록/조회 단위.
143
+ - 훅 진입점: stdin JSON(`session_id`) 파싱 → 매핑 부재 시 no-op, 존재 시 올바른 RUN_DIR 전달.
144
+ - e2e: 모의 worker 산출물 + 태그 pane 으로 "완료 즉시 회수, 진행 중 보존" 시나리오(tmux 가용 시).
145
+
146
+ ## 9. 미해결 / 리스크
147
+
148
+ - **~~teammate pane title 회수 가능성~~ → 해소(2026-06-18).** §6 구현 후 결론 참조: hook 경로의 teammate pane 회수는 구조적으로 불가(generic title + per-worker 태그 부재). teammate pane 은 lead 의 batch-경계 phase-start cleanup 이 회수하며, 그 단계는 아래 de-gate 로 항상 실행된다.
149
+ - **~~한 세션 다중 run~~ → 무효(미채택).** 구현은 `sessions/<id>.run` 세션 매핑(③) 대신 `okstra-subagent-reclaim.sh` 가 `active.jsonl` 의 활성 run 전체를 순회하고 per-pane 완료 판정(`pane_reclaim`)으로 cross-run 안전을 보장하는 방식을 택했다 — session_id 매핑이 없으므로 이 리스크는 발생하지 않는다(§5 데이터 흐름의 session 매핑 서술은 미구현). 동시 병렬 run 도 완료 판정이 막아준다.
150
+ - **④ 내성은 코드 강제 불가** — prompt 계약이라 lead 가 따르지 않으면 무효(이번 원인과 동일 계열). **보강(2026-06-18, `b2278a6`):** phase-start cleanup / wrap-up disposition 의 lead-pane.id 해석 게이트를 제거해 lead 가 cleanup 스크립트를 *항상* 호출하도록 바꿨다(스크립트가 tmux 밖이면 자가 no-op). 이로써 "lead 가 lead-pane.id 를 오판해 정리를 통째로 skip" 하는 이번 원인 계열이 일차 방어선(①②)에서 제거된다. 내성(④)은 그래도 한도에 닿는 경우의 최후 안전망으로 남는다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.89.0",
3
+ "version": "0.91.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.89.0",
3
- "builtAt": "2026-06-17T10:43:53.695Z",
2
+ "package": "0.91.0",
3
+ "builtAt": "2026-06-18T06:40:45.488Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -218,6 +218,7 @@ if [[ -n "$caller_pane" ]]; then
218
218
  # Tag with this run's dir for `okstra-trace-cleanup.sh --run-dir`. See
219
219
  # `okstra-codex-exec.sh` for the rationale — kept in lock-step.
220
220
  [[ -n "$run_dir" ]] && tmux set-option -p -t "$trace_pane" @okstra_trace_run "$run_dir" 2>/dev/null || true
221
+ [[ -n "$status_path" ]] && tmux set-option -p -t "$trace_pane" @okstra_status "$status_path" 2>/dev/null || true
221
222
  tmux last-pane 2>/dev/null || true
222
223
  fi
223
224
  fi
@@ -280,6 +280,7 @@ if [[ -n "$caller_pane" ]]; then
280
280
  # assumption. The run-scoped tag also stops concurrent okstra runs from
281
281
  # stomping each other's trace panes.
282
282
  [[ -n "$run_dir" ]] && tmux set-option -p -t "$trace_pane" @okstra_trace_run "$run_dir" 2>/dev/null || true
283
+ [[ -n "$status_path" ]] && tmux set-option -p -t "$trace_pane" @okstra_status "$status_path" 2>/dev/null || true
283
284
  tmux last-pane 2>/dev/null || true
284
285
  fi
285
286
  fi
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # okstra-subagent-reclaim.sh — SubagentStop / TaskCompleted 훅 엔트리.
4
+ #
5
+ # stdin(JSON)은 읽고 버린다: 어느 worker 가 끝났든 활성 run 전체의 "완료" pane 만
6
+ # 회수하므로 session_id 매핑이 필요 없다. active.jsonl 의 각 활성 run 에 대해
7
+ # okstra-trace-cleanup.sh --reclaim-completed 를 호출한다 — 완료 판정(pane_reclaim)이
8
+ # cross-run 안전을 보장하므로 다른 run 의 진행 중 pane 은 건드리지 않는다.
9
+ #
10
+ # 훅은 세션 진행을 절대 막지 않는다: 모든 실패는 조용히 삼키고 항상 exit 0.
11
+ set -u
12
+
13
+ cat >/dev/null 2>&1 || true # drain stdin
14
+
15
+ _dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
16
+ home="${OKSTRA_HOME:-$HOME/.okstra}"
17
+ # pane_reclaim 패키지는 repo 레이아웃(scripts/okstra_ctl)과 설치 레이아웃
18
+ # ($OKSTRA_HOME/lib/python) 양쪽에 있을 수 있으므로 둘 다 PYTHONPATH 에 둔다.
19
+ export PYTHONPATH="${_dir}:${home}/lib/python${PYTHONPATH:+:$PYTHONPATH}"
20
+
21
+ while IFS= read -r run_dir; do
22
+ [[ -n "$run_dir" ]] || continue
23
+ "$_dir/okstra-trace-cleanup.sh" --reclaim-completed --run-dir "$run_dir" >/dev/null 2>&1 || true
24
+ done < <(python3 -m okstra_ctl.pane_reclaim --active-dirs "$home" 2>/dev/null || true)
25
+
26
+ exit 0
@@ -46,24 +46,37 @@ set -u
46
46
  _clean_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
47
47
  [ -r "$_clean_script_dir/lib/okstra/tmux-pane.sh" ] && . "$_clean_script_dir/lib/okstra/tmux-pane.sh"
48
48
 
49
+ # --reclaim-completed shells out to `python3 -m okstra_ctl.pane_reclaim`. The
50
+ # package is a bin-sibling in the repo layout (scripts/okstra_ctl) and under
51
+ # $OKSTRA_HOME/lib/python in the installed layout. Put both on PYTHONPATH so the
52
+ # import resolves regardless of where this script runs from.
53
+ _okstra_home="${OKSTRA_HOME:-$HOME/.okstra}"
54
+ export PYTHONPATH="${_clean_script_dir}:${_okstra_home}/lib/python${PYTHONPATH:+:$PYTHONPATH}"
55
+
49
56
  MODE="kill" # kill | list
57
+ RECLAIM=0 # 1: trace pane 은 @okstra_status 가 완료(exited)일 때만 회수 (--reclaim-completed)
50
58
  REAP=0
51
59
  run_dir=""
52
60
  while [[ $# -gt 0 ]]; do
53
61
  case "$1" in
54
- --list|--dry-run) MODE="list" ;;
55
- --reap) REAP=1 ;;
62
+ --list|--dry-run) MODE="list" ;;
63
+ --reclaim-completed) RECLAIM=1 ;;
64
+ --reap) REAP=1 ;;
56
65
  --run-dir) shift; run_dir="${1-}" ;;
57
66
  --run-dir=*) run_dir="${1#--run-dir=}" ;;
58
67
  -h|--help)
59
68
  cat <<'USAGE'
60
- usage: okstra-trace-cleanup.sh (--run-dir <RUN_DIR> [--list] | --reap)
69
+ usage: okstra-trace-cleanup.sh (--run-dir <RUN_DIR> [--list] [--reclaim-completed] | --reap)
61
70
 
62
- --run-dir okstra run directory; closes that run's trace + worker-agent panes.
63
- --list with --run-dir: print "<pane_id>\t<pane_title>" per pane; no kill.
64
- --dry-run alias for --list.
65
- --reap close every okstra trace pane under $CLAUDE_PROJECT_DIR/.okstra
66
- (SessionEnd hook; no single run-dir applies).
71
+ --run-dir okstra run directory; closes that run's trace + worker-agent panes.
72
+ --list with --run-dir: print "<pane_id>\t<pane_title>" per pane; no kill.
73
+ --dry-run alias for --list.
74
+ --reclaim-completed with --run-dir: restrict trace panes to those whose
75
+ @okstra_status sidecar is terminal (stage=exited); in-flight
76
+ and teammate panes are preserved. Skips the title-allowlist
77
+ teammate scan. Combinable with --list.
78
+ --reap close every okstra trace pane under $CLAUDE_PROJECT_DIR/.okstra
79
+ (SessionEnd hook; no single run-dir applies).
67
80
  USAGE
68
81
  exit 0 ;;
69
82
  *)
@@ -117,24 +130,39 @@ _tag_in_scope() {
117
130
 
118
131
  collect_okstra_panes() {
119
132
  local -a panes=()
120
- local pid trace_tag worker_tag title
133
+ local pid trace_tag worker_tag status_tag title
121
134
 
122
135
  # (1) Trace and worker-compute panes tagged in scope — found server-wide by
123
- # tag, so no tmux env var or pane-id registry is needed.
124
- while IFS=$'\t' read -r pid trace_tag worker_tag; do
136
+ # tag, so no tmux env var or pane-id registry is needed. Each `@okstra_*`
137
+ # column carries a leading `x` sentinel (stripped after read): tmux's `-F`
138
+ # drops an UNSET user option AND its adjacent tab, which would otherwise
139
+ # shift later columns left (e.g. an empty worker tag stealing the status
140
+ # value). The sentinel keeps every column present so positional parsing holds.
141
+ # `#x` strip 은 실제 태그 값을 깎지 않는다: 모든 태그 값(trace_run/worker_run/
142
+ # status)은 절대경로라 `/` 로 시작 → `x` 로 시작하는 일이 없어 sentinel 만 벗겨진다.
143
+ while IFS=$'\t' read -r pid trace_tag worker_tag status_tag; do
144
+ trace_tag="${trace_tag#x}"; worker_tag="${worker_tag#x}"; status_tag="${status_tag#x}"
125
145
  [[ -n "$pid" ]] || continue
126
146
  [[ "$pid" == "$lead_pane" ]] && continue
127
147
  if _tag_in_scope "$trace_tag" || _tag_in_scope "$worker_tag"; then
148
+ if [[ "$RECLAIM" -eq 1 ]]; then
149
+ # reclaim 모드: 완료(stage=exited)된 worker 의 pane 만. status 태그가
150
+ # 없거나(teammate/비-wrapper pane) 미완료면 보존.
151
+ [[ -n "$status_tag" ]] || continue
152
+ python3 -m okstra_ctl.pane_reclaim "$status_tag" || continue
153
+ fi
128
154
  panes+=("$pid")
129
155
  fi
130
156
  done < <(tmux list-panes -a \
131
- -F '#{pane_id}'$'\t''#{@okstra_trace_run}'$'\t''#{@okstra_worker_run}' \
157
+ -F '#{pane_id}'$'\t''x#{@okstra_trace_run}'$'\t''x#{@okstra_worker_run}'$'\t''x#{@okstra_status}' \
132
158
  2>/dev/null || true)
133
159
  # (2) Title-allowlisted worker-agent panes in the lead's session. Only for a
134
160
  # run (reap leaves these harness-owned panes to the harness). `list-panes -s
135
161
  # -t <pane>` resolves the session containing that pane, so the scan never
136
162
  # reaches other sessions (no `-a`). Skipped when the lead pane is unknown.
137
- if [[ "$REAP" -eq 0 && -n "$lead_pane" ]]; then
163
+ # reclaim 모드는 teammate pane 회수하지 않으므로(완료 판정 불가, trace-only)
164
+ # 이 스캔을 건너뛴다.
165
+ if [[ "$REAP" -eq 0 && "$RECLAIM" -eq 0 && -n "$lead_pane" ]]; then
138
166
  while IFS=$'\t' read -r pid title; do
139
167
  [[ -n "$pid" ]] || continue
140
168
  [[ "$pid" == "$lead_pane" ]] && continue
@@ -268,6 +268,14 @@ Section numbering follows `templates/reports/final-report.template.md` exactly
268
268
 
269
269
  **Verdict Card (top-of-report, mandatory).** Render `## Verdict Card` between the report header and the (conditional) Approval block. Its `Verdict Token` / `Direction` / `Next Step` cells MUST byte-match the corresponding cells in `## 7. Final Verdict` and the first item of `## 6.`. Divergence is `contract-violated`.
270
270
 
271
+ **Background and Rationale (top-of-report, mandatory — every task-type).** Fill the data.json `rationale` object (rendered as `## 작업 배경과 근거`, right after the Verdict Card). It is the reviewer-facing narrative that answers four questions, in order — write each as **prose**, not a table:
272
+ - `motivation` — 왜 이 작업을 하는가 (목표·맥락).
273
+ - `problem` — 왜 이게 문제인가 (현재 상태의 결함).
274
+ - `approach` — 그래서 어떤 작업이 필요한가 (택한 방향).
275
+ - `justification` — 왜 이게 합리적 선택인가 (대안 대비, 근거).
276
+
277
+ Every field MUST anchor its claim with at least one evidence reference — a `path:line`, a report ID (`C-001` / `F-013` / `§5.4`), or another cited source already present in this report. When the evidence is genuinely insufficient, say so explicitly (`근거 불충분 — <이유>`) instead of inventing one. `validators/validate-run.py` → `_validate_rationale_evidence` fails any field carrying neither a citation nor an insufficiency marker, so an ungrounded or fabricated rationale is rejected at validation time. Do NOT restate the Verdict Card or the §5.4 trade-off matrix verbatim — this section is the *why*, in connected prose, that those tables compress.
278
+
271
279
  0. **Clarification Response Carried In** — render this `## 0.` heading ONLY when `{{CLARIFICATION_RESPONSE_RELATIVE_PATH}}` is non-empty. Walk every `C-*` row of the prior report's `## 1. Clarification Items` table, reconcile against new evidence, and record the outcome (`resolved` / `obsolete`) with citation before drafting the verdict. When no carry-in path was provided, OMIT the `## 0.` heading entirely — the validator fails an empty Section 0 stub.
272
280
  1. **Cross Verification Results** — 4 categories (Full / Partial / Contested / Worker-Unique) when convergence is enabled, per `convergence`. Prepend the Round History sub-table (columns: `Round | inputQueueSize | resolvedCount | carriedForwardCount | dispatches | skippedWorkers`) plus a `round2SkippedReason: <value>` note, pulled verbatim from `convergence-<task-type>-<seq>.json`. Empty contested list renders as `- 합의 미달 항목 없음.`. Convergence-disabled runs use the legacy Consensus/Differences format and omit the round table.
273
281
  2. **Final Verdict** — `Direction` ∈ `continue-investigation` / `begin-implementation` / `approve` / `reject` / `hold`. **Verdict Token** is `not-applicable` for every task-type except `final-verification` — see "Final-verification verdict token contract" below for that case.
@@ -281,7 +289,7 @@ Section numbering follows `templates/reports/final-report.template.md` exactly
281
289
 
282
290
  ### Writing Guidelines
283
291
 
284
- - Write in Markdown. **Prefer tables over prose bullet lists** for any section that enumerates multiple items with the same shape (evidence rows, risks, options, dependencies, rollback steps, follow-ups, open questions). Bullets are reserved for short, single-line standalone statements (e.g., "- 추가 정보 요청 없음."). When the template provides a table form, do NOT degrade it back to bullets in the rendered report.
292
+ - Write in Markdown. **Prefer tables over prose bullet lists** for any section that enumerates multiple items with the same shape (evidence rows, risks, options, dependencies, rollback steps, follow-ups, open questions). Bullets are reserved for short, single-line standalone statements (e.g., "- 추가 정보 요청 없음."). When the template provides a table form, do NOT degrade it back to bullets in the rendered report. **Exception — `## 작업 배경과 근거` (`rationale`) is deliberately prose**: it is connected narrative explaining the *why*, not a same-shape enumeration, so write full sentences there rather than forcing it into a table.
285
293
  - Write the final report body in the language passed in **Report Language**
286
294
  above (`en` or `ko`). The template's fixed labels (section asides,
287
295
  empty-states, token summary, column headers, release-handoff labels)
@@ -16,6 +16,7 @@ profile document.
16
16
  - **Phase 4 / 5 (independent analysis)**: analyser workers (`claude`, `codex`, `antigravity` when opted in) produce findings independently and have no access to one another's outputs. `report-writer` does not analyse.
17
17
  - **Phase 5.5 (convergence — peer review by workers)**: the lead replays each analyser's findings to the *other* analysers and collects `AGREE` / `DISAGREE` / `SUPPLEMENT` verdicts across up to `effectiveMaxRounds` rounds. Workers act as peer reviewers of each other's findings in this phase; the lead mediates but does not vote. See `prompts/lead/convergence.md` for the round protocol, queue invariants, and final classification (`full-consensus` / `partial-consensus` / `contested` / `worker-unique`). For `requirements-discovery`, `error-analysis`, and `implementation-planning` this phase runs in **adversarial mode** (`convergence.adversarial=true`): verifiers try to refute each finding against its cited evidence and the burden of proof sits on the claim — see that skill's §"Adversarial Verification Mode".
18
18
  - Do NOT conclude "no peer review happens" from the roster alone — every profile that lists ≥2 analyser workers runs convergence by default (`convergence.enabled=true` in `task-manifest.json`).
19
+ - **Pane-budget fallback (tolerance).** If a worker dispatch is rejected with `no room for another tmux split` (or an equivalent teammate-pane creation failure), the lead retries that worker in-process without `run_in_background`, or — if it was an external CLI worker — **substitutes** an in-process `claude` analysis, and records that fact (provider unavailable) in the run log and the convergence notes. Completed external-CLI worker trace panes are reclaimed automatically by the `SubagentStop` hook, but okstra cannot directly reclaim the teammate panes the harness creates, so this fallback is the last line of defence against pane-budget exhaustion. (This is a prompt instruction, not a code-enforced gate.)
19
20
  - Tooling — read-only MCP availability (shared):
20
21
  - MCP is not implicit okstra context. Query an MCP server only when the task brief explicitly lists it as source material for this run. Any MCP-derived finding MUST cite server, table, and the SELECT used. MCP MUST NEVER be used as a write path — schema/data mutations go through repository migration files reviewed by humans.
21
22
  - Resource boundary (shared — artifact-home rule):
@@ -30,11 +31,11 @@ profile document.
30
31
  - Anti-escalation rule (shared):
31
32
  - treating "다음 단계 진행해" or equivalent user phrases as authorisation to start a *different* lifecycle phase is forbidden. The next phase begins only in a separate okstra run launched with the new `--task-type`. Per-profile documents may further restrict this within their own scope.
32
33
  - Run-start pane recording (shared — runs ONCE at run start, before the FIRST worker dispatch):
33
- - The codex/antigravity wrappers now self-anchor their trace pane by walking their own ancestor PIDs against tmux `pane_pid`s (see `lib/okstra/tmux-pane.sh`), so they no longer depend on this file. The lead still records its own pane id here for the cleanup steps below (which-pane-to-never-kill) and as the "am I in tmux" gate. A bare `tmux display-message -p '#{pane_id}'` is NOT reliable for this — Claude Code's Bash tool strips `$TMUX`/`$TMUX_PANE`, so that command returns the most-recently-active *client's* pane (often a different session, or a foreign pane when the lead is launched outside tmux entirely). The lead therefore records via the same ancestry resolver.
34
- - The lead MUST run once, at run start: `mkdir -p "<RUN_DIR>/state" && { . "$HOME/.okstra/bin/lib/okstra/tmux-pane.sh" 2>/dev/null && okstra_resolve_caller_pane; } > "<RUN_DIR>/state/lead-pane.id" 2>/dev/null || true` (substitute the run's absolute `RUN_DIR`). When the lead is not inside a tmux pane (e.g. Claude launched from the GUI app) no ancestor matches a pane, the file is empty, and every pane step below silently no-ops that empty/absent file is the single signal that the lead is not in tmux.
35
- - This recording is **silent internal setup**: the lead MUST emit NOTHING to the user about it — no pane id, no `%NNN`, no `lead-pane.id` path, no announcement that the phase-start-cleanup / wrap-up-disposition steps "activate". Just record the file and proceed.
34
+ - The codex/antigravity wrappers now self-anchor their trace pane by walking their own ancestor PIDs against tmux `pane_pid`s (see `lib/okstra/tmux-pane.sh`), so they no longer depend on this file. The lead still records its own pane id here so the cleanup script below can know which pane to never kill and can detect tmux absence itself — the lead does NOT read it back to gate those steps. A bare `tmux display-message -p '#{pane_id}'` is NOT reliable for this — Claude Code's Bash tool strips `$TMUX`/`$TMUX_PANE`, so that command returns the most-recently-active *client's* pane (often a different session, or a foreign pane when the lead is launched outside tmux entirely). The lead therefore records via the same ancestry resolver.
35
+ - The lead MUST run once, at run start: `mkdir -p "<RUN_DIR>/state" && { . "$HOME/.okstra/bin/lib/okstra/tmux-pane.sh" 2>/dev/null && okstra_resolve_caller_pane; } > "<RUN_DIR>/state/lead-pane.id" 2>/dev/null || true` (substitute the run's absolute `RUN_DIR`). When the lead is not inside a tmux pane (e.g. Claude launched from the GUI app) no ancestor matches a pane and the file is empty. The cleanup steps below do NOT read this file to decide whether to run — they always run, and the script itself reads `lead-pane.id` (with its own ancestry fallback) only to know which pane to never kill and to detect tmux absence. The lead never interprets this file to gate a pane step.
36
+ - This recording is **silent internal setup**: the lead MUST emit NOTHING to the user about it — no pane id, no `%NNN`, no `lead-pane.id` path, no announcement of the phase-start-cleanup / wrap-up-disposition steps. Just record the file and proceed.
36
37
  - Phase-start cleanup — prior-batch panes + completed teammates (shared — runs BEFORE dispatching each new worker batch, at the SAME boundaries: just before each `PROGRESS: phase-5.5-convergence round=<N>` marker and just before `PROGRESS: phase-6-synthesis dispatching report-writer-worker`):
37
- - **(a) tmux panes.** okstra creates two kinds of tmux pane per run: **worker-agent panes** the harness gives to dispatched subagents (titled `claude-worker` / `codex-worker` / `antigravity-worker` / `report-writer-worker`), and **trace panes** the codex/antigravity wrappers spawn (`<cli>-<role>-<pid>-tail`). Both accumulate because each new batch spawns fresh panes and the prior ones are never reclaimed. When `<RUN_DIR>/state/lead-pane.id` is non-empty (the lead is in tmux), the lead MUST run `$HOME/.okstra/bin/okstra-trace-cleanup.sh --run-dir "<RUN_DIR>"` immediately before dispatching the next batch. This closes every prior-batch okstra pane (worker-agent + trace) for this run, while NEVER killing the lead's own pane. Silent-skipped when the lead is not in tmux; the lead MUST NOT fabricate a synthetic pane list in that case.
38
+ - **(a) tmux panes.** okstra creates two kinds of tmux pane per run: **worker-agent panes** the harness gives to dispatched subagents (titled `claude-worker` / `codex-worker` / `antigravity-worker` / `report-writer-worker`), and **trace panes** the codex/antigravity wrappers spawn (`<cli>-<role>-<pid>-tail`). Both accumulate because each new batch spawns fresh panes and the prior ones are never reclaimed. The lead MUST ALWAYS run `$HOME/.okstra/bin/okstra-trace-cleanup.sh --run-dir "<RUN_DIR>"` immediately before dispatching the next batch — NEVER gating this call on its own reading of `lead-pane.id`. The script self-resolves the lead pane and is a safe no-op outside tmux (it returns an empty pane list), so an always-run call cannot do harm; it closes every prior-batch okstra pane (worker-agent + trace) for this run while NEVER killing the lead's own pane. The lead MUST NOT fabricate a synthetic pane list.
38
39
  - **(b) completed teammates (Teams mode only).** Each convergence round / critic / gap-verification / report-writer dispatch spawns teammates under fresh Agent `name`s (`<role>-reverify-r<N>`, `<provider>-worker-critic`, `report-writer-worker`, …); completed teammates do NOT leave the FleetView roster on their own — they linger as idle pills until run-end teardown, so a long run's roster grows unreadable. Before dispatching the next batch, the lead MUST send `SendMessage(to: <name>, message: { type: "shutdown_request" })` — the `message` MUST be the object literal shown, NEVER a JSON string stuffed into a text field — to every teammate it spawned earlier in THIS run **whose completion is already confirmed** (its Result Path exists / it is in the self-scheduled-polling done set). This is the same graceful-shutdown primitive used at `Run-end teammate teardown`, applied per batch instead of once at the end.
39
40
  - **Only confirmed-complete teammates, never the lead.** NEVER send a shutdown_request to a teammate whose result file has not yet appeared. In particular the critic runs CONCURRENTLY with the first reverify round (see `convergence` "Coverage critic pass"), so it MUST NOT be shut down until its own result is collected. NEVER target the lead's own session.
40
41
  - **Do NOT call `TeamDelete` here.** The team stays alive for the remaining batches; only individual completed teammates are dismissed. `TeamDelete` remains a run-end-only step (`Run-end teammate teardown`).
@@ -42,7 +43,7 @@ profile document.
42
43
  - Both (a) and (b) are **automatic and silent** — NO user prompt. Report the pair in one short, plain-language line (e.g. `이전 배치 okstra pane 3개 · teammate 2명 정리`) and proceed — never expose internal identifiers (`%NNN`, `lead-pane.id`, raw Agent names, step names like "phase-start cleanup"). **Effect caveat:** Claude Code exposes no tool to surgically delete a roster entry mid-run, so a dismissed teammate may still show as an idle pill; the shutdown still reclaims its session and prevents the roster from growing with live members. On the FIRST batch of a run nothing has been dispatched yet — both steps no-op.
43
44
  - Phase wrap-up — okstra pane disposition (shared, runs AFTER Phase 7 persistence/token collection and BEFORE teammate teardown):
44
45
  - At run end the only residual okstra panes are the LAST phase's (e.g. the `report-writer-worker` agent pane and any codex/antigravity trace pane). `okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>"` returns one tab-separated `<pane_id>\t<pane_title>` line per residual okstra pane (worker-agent + trace) for this run.
45
- - When `<RUN_DIR>/state/lead-pane.id` is non-empty, after the final-report file has been written and the routing recommendation has been issued, the lead MUST run `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>"` exactly once. The output lists every residual okstra pane (worker-agent + trace) for this run, never the lead's own pane.
46
+ - After the final-report file has been written and the routing recommendation has been issued, the lead MUST ALWAYS run `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>"` exactly once — NEVER gating this call on its own reading of `lead-pane.id`. The output lists every residual okstra pane (worker-agent + trace) for this run, never the lead's own pane; outside tmux it is a safe no-op (empty output).
46
47
  - If the list is empty, skip the question — there is nothing to ask about (the phase-start resets above usually already cleared prior phases).
47
48
  - Otherwise the lead MUST present the user with a strict binary choice **before** declaring the phase complete. Use one prompt of this shape (Korean preferred, English acceptable if the rest of the run is in English):
48
49
  > 현재 phase 종료 시점입니다. 다음 okstra pane 이 열려 있습니다 — 닫을까요?
@@ -51,7 +52,7 @@ profile document.
51
52
  - On `예` / `y` / `close` → run `$HOME/.okstra/bin/okstra-trace-cleanup.sh --run-dir "<RUN_DIR>"` and report the kill count back in one sentence.
52
53
  - On `아니오` / `n` / `keep` → leave the panes intact; remind the user that they will be cleaned up automatically when Claude `/exit` fires the `SessionEnd` hook (`--reap`).
53
54
  - The question MUST be a clean yes/no — do NOT offer "close some / keep some" partial answers, do NOT propose alternatives like "close only codex panes". The whole-set decision keeps the wrap-up predictable.
54
- - This step is mandatory for every phase (`requirements-discovery`, `error-analysis`, `implementation-planning`, `implementation`, `final-verification`, `release-handoff`). It is silent-skipped when `<RUN_DIR>/state/lead-pane.id` is empty/absent (lead running outside tmux); the lead MUST NOT fabricate a synthetic pane list in that case.
55
+ - This step is mandatory for every phase (`requirements-discovery`, `error-analysis`, `implementation-planning`, `implementation`, `final-verification`, `release-handoff`). Whether there is anything to ask about is decided by the `--list` output, NOT by the lead's reading of `lead-pane.id`: an empty list skip the question (above); the lead MUST NOT fabricate a synthetic pane list.
55
56
  - Run-end teammate teardown (shared — MUST run AFTER the pane disposition question and BEFORE `PROGRESS: complete`):
56
57
  - The lead created the worker team in Phase 3 (`TeamCreate` with the name recorded as team-state `teamName` — `okstra-<task-key>`, implementation stage runs `okstra-<task-key>-s<N>`). Worker teammates are NOT reclaimed on their own — without an explicit teardown they linger in the FleetView roster across this and later runs in the session. A lingering team also makes the next run of the same task-key collide on `TeamCreate` ("team already exists"), forcing the stale-team recovery path.
57
58
  - **Whether to run this step is decided by on-disk fact, NOT by `teamCreate.status` alone.** A mid-run context compaction can make the lead wrongly conclude Agent Teams is unavailable and record `teamCreate.status: "error"` even though `TeamCreate` had succeeded and workers joined — so at run-end `status` is not a trustworthy "is there a team" signal. The harness names the team directory `session-<leadSessionId-prefix>` (NOT `okstra-<task-key>`; the `team_name` passed to `TeamCreate` is a logical label that never becomes a directory name). Resolve existence from disk: find the `~/.claude/teams/session-*/config.json` whose `leadSessionId` equals team-state `lead.sessionId`.
@@ -102,7 +103,7 @@ profile document.
102
103
  - each row's `Kind` column picks one of `{material, decision, data-point}`: `material` for files / snapshots / logs / screenshots the user must attach (the `User input` cell will hold a path or URL); `decision` for choices and yes/no confirmations only the user can make; `data-point` for a single number, ID, date, or short string the user can answer inline. Items that mix "yes/no + file path if yes" are one row of `Kind=material` with the combined expectation written into `Expected form`.
103
104
  - each row's `Blocks` column picks one of `{approval, next-phase, none}`. `approval` is reserved for items that gate an approval action, especially the `implementation-planning` `approved:` frontmatter flip; outside `implementation-planning`, unresolved brief reporter-confirmation rows use `next-phase` instead. `next-phase` blocks the next run from starting cleanly. `none` is informational/audit-only.
104
105
  - write every entry in full, descriptive sentences that a non-developer can act on without further context. Avoid abbreviations and internal jargon. The `Statement` cell must state *what* is needed, *why* the answer / attachment changes the next step, and (for `material`) *where* the user can find it and *where* to place it. The `Expected form` cell must state the answer shape (예/아니오, 보기 중 하나, 숫자/날짜, 파일 경로, 짧은 서술 등); supply concrete option choices when applicable.
105
- - if a phase requires a recommended answer, alternatives, or an evidence-check note, encode it inside the existing 4-column schema: put evidence notes in `Statement` as `Evidence checked: <path:line>` or `Evidence checked: none — <human-only reason>`, and put recommendations/options in `Expected form` as `Recommended: <answer> — <rationale>; Alternatives: <options>`. Do not add `Recommended`, `Evidence`, `Alternatives`, or `evidence-checked` columns, and do not break the merged record-meta cell back into separate columns.
106
+ - if a phase requires a recommended answer, alternatives, or an evidence-check note, encode it inside the existing 4-column schema: put evidence notes in `Statement` as `Evidence checked: <path:line>` or `Evidence checked: none — <human-only reason>`, and put recommendations/options in `Expected form` as `Recommended: <answer> — <rationale>; Alternatives: <options>`. When there is more than one alternative, enumerate them as `(a) … (b) …` so each renders as its own selectable option; a single alternative may be written plainly. Do **not** append a pick-one answer-space summary such as `(A / B 중 택1)` or `(… 중 택N)` to `<options>` — the rendered `<select>` already enforces single choice, and that annotation leaks verbatim into an option label. Do not add `Recommended`, `Evidence`, `Alternatives`, or `evidence-checked` columns, and do not break the merged record-meta cell back into separate columns.
106
107
  - the same `final-report.md` file is the canonical artifact carried into the next run; the user appends answers inline before rerunning. The preferred turn-around is `scripts/okstra.sh --resume-clarification --task-key <project-id>:<task-group>:<task-id>` (opens the latest report in `$EDITOR`, then auto-reruns the same phase with `--clarification-response` carry-in). The lower-level form `--clarification-response <path>` remains available for scripted runs.
107
108
  - if a clarification response was carried in for this run, render the conditional `## 0. Clarification Response Carried In From Previous Run` section (the template's `RENDER_IF` guard activates it), walk every `C-*` row of the prior report's `## 1. Clarification Items` table, reconcile each one against new evidence, and update its `Status` to `resolved` or `obsolete` before issuing the next decision/verdict. When no carry-in path was provided, omit the `## 0.` heading entirely — the validator fails reports that emit an empty Section 0 stub (e.g. "No prior clarification response was provided for this run.").
108
109
  - Verdict Card (shared — applies to every final-report regardless of profile):
@@ -59,7 +59,8 @@ until Phase 5 ends, then drop from active context for Phase 6/7.
59
59
  - read-only inspection commands: `git status`, `git diff`, `git log`, `grep`, `rg`, `find`, `cat`, `ls`, file Read tools
60
60
  - build, lint, type-check, and test commands (`npm test`, `pytest`, `go build`, `cargo test`, `bash -n`, etc.)
61
61
  - **local git operations only**: `git add`, `git commit`. Prefer small commits keyed to plan steps.
62
- - **No okstra artifacts in commits (BLOCKING).** Never use `git add -f`. Before every `git commit`, run `git diff --cached --name-only` and confirm it contains zero `.okstra/` paths (and zero `.project-docs/` paths when the legacy symlink is present). `.okstra/**` is gitignored; force-staging it onto the stage branch is the one way these verification artifacts reach the upstream PR. Conformance/qa evidence belongs in the carry sidecar and verifier result committing it is never correct, even when a step's instructions seem to ask for it.
62
+ - **Explicit-path staging only (BLOCKING).** Stage exactly the files a plan step touches by naming each path — `git add <path1> <path2>`. Never use the bulk forms `git add .`, `git add -A`, `git add -u`, or `git add --all`: they sweep in unrelated edits, stray build output, and editor scratch files. Never use `git add -f` (force) under any circumstance `-f` is the *only* way a `.gitignore`d path reaches a commit, and a forced ignored file is always a defect. If `git add <path>` reports the path is ignored, that file does not belong in the commit; do not override it — leave it untracked.
63
+ - **No ignored / okstra files in commits (BLOCKING).** Before every `git commit`, run `git diff --cached --name-only` and pipe the staged paths through `git check-ignore --stdin --no-index`; abort the commit if it reports **any** match — a staged path that `.gitignore` excludes means a bulk-add or `-f` leaked it in. The check explicitly covers `.okstra/` (and `.project-docs/` when the legacy symlink is present): `.okstra/**` is gitignored, and force-staging it onto the stage branch is the one way these verification artifacts reach the upstream PR. Conformance/qa evidence belongs in the carry sidecar and verifier result — committing it is never correct, even when a step's instructions seem to ask for it.
63
64
  - **Commit message format (mandatory)**: every commit message MUST follow Conventional Commits — `<type>(<scope>): <subject>` for the first line, optional body separated by a blank line, optional footer. Constraints:
64
65
  - `<type>` MUST be one of: `feat` / `fix` / `perf` / `revert` / `deps` / `docs` / `refactor` / `build` / `ci` / `chore` / `test`. When the repo is `release-please`-managed, this aligns the commit with a configured changelog section.
65
66
  - `<scope>` SHOULD be the plan step identifier or the primary module touched (e.g. `feat(report-writer): ...`). Omit the parentheses only when no meaningful scope applies.
@@ -97,7 +97,7 @@ Re-running commands proves the diff *builds and passes*; it does NOT prove the d
97
97
  - **Tautological delegation assertion:** a test asserts the SUT result equals a direct call to the same pure helper/collaborator that the SUT delegates to, instead of asserting an independent literal value or observable state.
98
98
  - **Untruthful name:** a read-named function (`get*` / `find*` / `load*`) that writes/inserts/mutates; an adapter or repository name encoding the caller's use-case (`*ForInit`) or hiding a domain rule (`findValid*` / `findActive*`).
99
99
  - **Hexagonal (only when the overlay is loaded):** business logic inside a port body; an adapter method that is not pure I/O (post-fetch JS filtering on domain state, domain-rule evaluation); a domain object declared outside the `domain/` boundary.
100
- - **okstra artifact committed to the branch:** any path in the `git diff --name-only <base>...HEAD` enumeration that lives under `.okstra/` (or `.project-docs/` when the legacy symlink is present). `.okstra/**` is gitignored, so a committed okstra file means the executor force-staged it (`git add -f`) leaking verification artifacts (qa scripts, conformance results) into the eventual PR. Cite the path; recommend `git rm --cached <path>` to untrack it while keeping the file on disk. Conformance/qa evidence belongs in the carry sidecar / verifier result, never in git history.
100
+ - **gitignored file committed to the branch:** any path in the `git diff --name-only <base>...HEAD` enumeration that `.gitignore` excludes enumerate them by piping that list through `git check-ignore --stdin --no-index`. A committed ignored file means the executor bulk-added (`git add .`/`-A`) or force-staged (`git add -f`) it, leaking build output, scratch files, or verification artifacts into the eventual PR. This explicitly includes `.okstra/` paths (and `.project-docs/` when the legacy symlink is present): `.okstra/**` is gitignored, so a committed okstra file (qa scripts, conformance results) is always this defect. Cite each path; recommend `git rm --cached <path>` to untrack it while keeping the file on disk. Conformance/qa evidence belongs in the carry sidecar / verifier result, never in git history.
101
101
  - **Real-IO test in source tree:** a changed/added test under the project source test tree — `src/**`, `test/**`, `tests/**`, `**/__test__/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*` — that opens a **real** DB connection / DSN, makes a real `fetch` / `axios` / `http` request, or otherwise hits real external IO without mocking the injected collaborator (a live handle, not a stub/spy). Real-IO tests MUST live under `<task_root>/qa/scripts/` per the executor's *Real-IO test isolation* rule — a live-IO test in source silently breaks the project's CI suite and violates the artifact-home rule. Cite the test file + the real-IO line; recommend moving it to `<task_root>/qa/scripts/` (or declaring it as a Tier 3 conformance script). Mock-only unit tests in source are NOT a hit.
102
102
  - **Advisory findings (recorded as recommendations; verdict MAY still PASS):** function >50 effective lines, a single body mixing read+write stages, weak readability, a missing-but-non-critical outcome assertion, newly orphaned private/public code that is safe to remove but not on a critical path, or weak-but-not-misleading names. These land in the verifier result as `should-fix` / `nit` recommendations, not as a `FAIL`.
103
103
  - **Output.** Every finding — blocking or advisory — is a structured item in the verifier's worker result (`path:line`, rule, severity, suggested fix) so it carries into Phase 5.5 convergence and the final report. A blocking hit sets the verifier verdict to `FAIL` with the rule cited, using the same verdict machinery as the Discrepancy rule above. `Claude lead` MUST NOT silently downgrade a cited blocking finding to advisory during synthesis; an override requires a concrete cited reason, exactly as for the Discrepancy rule.
@@ -0,0 +1,55 @@
1
+ """완료(terminal) worker 판정 — pane 회수용. 판정 SSOT 는
2
+ wrapper_status.read_wrapper_status 이며 여기서 재사용한다."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .reconcile import NON_TERMINAL_RECENT_STATUSES
10
+ from .wrapper_status import read_wrapper_status
11
+
12
+
13
+ def is_completed_status(status_path: str) -> bool:
14
+ status = read_wrapper_status(Path(status_path))
15
+ return status is not None and status.is_terminal
16
+
17
+
18
+ def active_run_dirs(home: Path) -> list[Path]:
19
+ """active.jsonl 의 진행 중 run 들의 절대 run-dir 목록.
20
+ runDirRel 의 base 는 projectRoot(paths.compute_run_paths 의 _rel 기준)."""
21
+ path = home / "active.jsonl"
22
+ if not path.is_file():
23
+ return []
24
+ dirs: list[Path] = []
25
+ for line in path.read_text(encoding="utf-8").splitlines():
26
+ line = line.strip()
27
+ if not line:
28
+ continue
29
+ try:
30
+ row = json.loads(line)
31
+ except json.JSONDecodeError:
32
+ continue
33
+ rel = row.get("runDirRel")
34
+ root = row.get("projectRoot")
35
+ # 회수 대상(진행 중) 집합은 reconcile 의 NON_TERMINAL_RECENT_STATUSES 가
36
+ # SSOT. reserving 은 아직 run-dir 산출물이 없어(= 회수할 pane 이 없어) 이
37
+ # 집합에 들어있지 않으므로 자연히 제외된다(allowlist).
38
+ if not rel or not root or row.get("status") not in NON_TERMINAL_RECENT_STATUSES:
39
+ continue
40
+ dirs.append(Path(root) / rel)
41
+ return dirs
42
+
43
+
44
+ def main(argv: list[str]) -> int:
45
+ if len(argv) == 2 and argv[0] == "--active-dirs":
46
+ for run_dir in active_run_dirs(Path(argv[1])):
47
+ print(run_dir)
48
+ return 0
49
+ if len(argv) == 1:
50
+ return 0 if is_completed_status(argv[0]) else 1
51
+ return 1
52
+
53
+
54
+ if __name__ == "__main__":
55
+ raise SystemExit(main(sys.argv[1:]))