okstra 0.89.0 → 0.90.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.89.0",
3
+ "version": "0.90.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.90.0",
3
+ "builtAt": "2026-06-17T18:09:19.361Z",
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
@@ -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`.
@@ -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:]))
@@ -140,7 +140,7 @@ def render_html(
140
140
  f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
141
141
  f"</header>\n"
142
142
  f"<main>{body_html}</main>\n"
143
- f"{_plan_approval_section(approval_ctx) if approval_ctx else ''}"
143
+ f"{_plan_approval_section(approval_ctx, run_meta) if approval_ctx else ''}"
144
144
  f"<footer class=\"report-footer\">\n"
145
145
  f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
146
146
  f" <p class=\"user-response-hint\">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — "
@@ -821,10 +821,13 @@ def report_has_clarification_items(src_md: str) -> bool:
821
821
  @dataclass(frozen=True)
822
822
  class PlanApprovalContext:
823
823
  """Plan Approval 위젯 렌더 입력. ``disabled_reason`` 이 비어 있지 않으면
824
- 위젯은 disabled 로 렌더된다 (run-prep 승인 게이트와 동일한 fail-closed 기준)."""
824
+ 위젯은 disabled 로 렌더된다 (run-prep 승인 게이트와 동일한 fail-closed 기준).
825
+ ``blocker_ids`` 는 §1 미해소 승인-차단 항목 ID 목록 — 화면 안내가 사용자에게
826
+ '어느 항목을 답해야 하는지' 를 보여주는 데 쓴다 (unreadable 사유면 빈 튜플)."""
825
827
  option_names: tuple[str, ...]
826
828
  recommended_option: str
827
829
  disabled_reason: str
830
+ blocker_ids: tuple[str, ...]
828
831
 
829
832
 
830
833
  def plan_approval_context(
@@ -853,28 +856,71 @@ def plan_approval_context(
853
856
  )
854
857
  if not names:
855
858
  return None
856
- recommended = ""
857
859
  rec = planning.get("recommendedOption")
858
- if isinstance(rec, dict):
859
- recommended = rec.get("name") or ""
860
- if recommended not in names:
861
- recommended = names[0]
860
+ rec_name = rec.get("name") or "" if isinstance(rec, dict) else ""
861
+ recommended = _resolve_recommended_option(rec_name, names)
862
862
  scan = scan_approval_gate(src_text)
863
+ blocker_ids: tuple[str, ...] = ()
863
864
  if scan.unreadable_reason:
864
865
  reason = "§1 승인 게이트를 읽을 수 없어 승인이 비활성화되었습니다 — 보고서를 재렌더하세요."
865
866
  elif scan.blockers:
866
- reason = (
867
- f"§1 승인 차단 항목 {len(scan.blockers)}건 미해소 — 답변을 채워 Export 한 뒤 "
868
- "resume-clarification 으로 해소한 다음 보고서에서 승인하세요."
869
- )
867
+ blocker_ids = tuple(b.row_id for b in scan.blockers)
868
+ reason = f"§1 승인 차단 항목 {len(scan.blockers)}건 미해소"
870
869
  else:
871
870
  reason = ""
872
871
  return PlanApprovalContext(
873
- option_names=names, recommended_option=recommended, disabled_reason=reason
872
+ option_names=names,
873
+ recommended_option=recommended,
874
+ disabled_reason=reason,
875
+ blocker_ids=blocker_ids,
876
+ )
877
+
878
+
879
+ def _resolve_recommended_option(rec_name: str, names: tuple[str, ...]) -> str:
880
+ """``recommendedOption.name`` 을 후보 이름에 맞춘다. 정확 일치가 없으면
881
+ 접두 일치(후보가 ``(RECOMMENDED)`` 같은 표식 접미를 더 달고 있는 흔한 경우)를
882
+ 허용하고, 그래도 없으면 첫 후보로 폴백한다 — 항상 정확히 한 옵션이 selected 가
883
+ 되어 브라우저 자동선택분의 묵시 Export 를 차단한다."""
884
+ if rec_name in names:
885
+ return rec_name
886
+ if rec_name:
887
+ for name in names:
888
+ if name.startswith(rec_name) or rec_name.startswith(name):
889
+ return name
890
+ return names[0]
891
+
892
+
893
+ def _approval_blocked_guidance(ctx: PlanApprovalContext, run_meta: RunMeta) -> str:
894
+ """disabled 사유를 화면에서 바로 따라할 수 있는 단계별 안내로 렌더한다.
895
+ 핵심: 이 화면에서 답을 입력하는 것만으로는 승인이 풀리지 않으며,
896
+ Export → 저장 → resume-clarification → 재렌더 라운드트립이 필요하다는 것."""
897
+ if not ctx.blocker_ids: # unreadable 사유 — 재렌더 외에 따라할 단계가 없다.
898
+ return f'<p class="approval-disabled-reason">{html.escape(ctx.disabled_reason)}</p>'
899
+ sidecar_dir = f"runs/{run_meta.task_type}/user-responses/"
900
+ ids = ", ".join(ctx.blocker_ids)
901
+ steps = [
902
+ f"위 <strong>§1 Clarification Items</strong> 표에서 차단 항목 "
903
+ f"<strong>{html.escape(ids)}</strong> 의 'User input' 칸에 답을 입력합니다.",
904
+ "헤더 또는 맨 아래의 <strong>[Export user response]</strong> 버튼을 눌러 응답 파일을 내려받습니다.",
905
+ f"내려받은 파일을 <code>{html.escape(sidecar_dir)}</code> 에 그대로 저장합니다.",
906
+ f"터미널에서 <code>scripts/okstra.sh --resume-clarification --task-key "
907
+ f"{html.escape(run_meta.task_key)}</code> (Claude Code 에서는 "
908
+ f"<code>/okstra-run resume-clarification task-key={html.escape(run_meta.task_key)}</code>) "
909
+ "을 실행합니다.",
910
+ "명령이 끝나면 <strong>새로 생성된 보고서</strong>를 여세요 — "
911
+ "그 보고서에서 이 체크박스가 활성화됩니다.",
912
+ ]
913
+ lis = "".join(f"<li>{s}</li>" for s in steps)
914
+ return (
915
+ '<div class="approval-disabled-reason">'
916
+ f"<p><strong>{html.escape(ctx.disabled_reason)}</strong>라 아직 승인할 수 없습니다. "
917
+ "이 화면에서 답만 입력해서는 풀리지 않습니다 — 아래 순서로 해소하세요:</p>"
918
+ f'<ol class="approval-steps">{lis}</ol>'
919
+ "</div>"
874
920
  )
875
921
 
876
922
 
877
- def _plan_approval_section(ctx: PlanApprovalContext) -> str:
923
+ def _plan_approval_section(ctx: PlanApprovalContext, run_meta: RunMeta) -> str:
878
924
  disabled = " disabled" if ctx.disabled_reason else ""
879
925
  opts: list[str] = []
880
926
  for name in ctx.option_names:
@@ -883,8 +929,7 @@ def _plan_approval_section(ctx: PlanApprovalContext) -> str:
883
929
  attrs = ' data-recommended="true" selected' if is_rec else ""
884
930
  opts.append(f'<option value="{html.escape(name)}"{attrs}>{html.escape(label)}</option>')
885
931
  reason_html = (
886
- f'\n <p class="approval-disabled-reason">{html.escape(ctx.disabled_reason)}</p>'
887
- if ctx.disabled_reason else ""
932
+ "\n " + _approval_blocked_guidance(ctx, run_meta) if ctx.disabled_reason else ""
888
933
  )
889
934
  return (
890
935
  '<section id="plan-approval">\n'
@@ -18,7 +18,9 @@ imports this module directly.
18
18
  """
19
19
  from __future__ import annotations
20
20
 
21
+ import copy
21
22
  import json
23
+ import math
22
24
  import re
23
25
  import subprocess
24
26
  from dataclasses import asdict, dataclass, field
@@ -3235,6 +3237,83 @@ def next_prompt(state: WizardState) -> Prompt:
3235
3237
  return Prompt(step=S_DONE, kind="done")
3236
3238
 
3237
3239
 
3240
+ def _passed_screens(state: WizardState) -> int:
3241
+ """이미 답한 화면 수. pick_group 멤버는 GROUP_MAX_TABS 묶음당 1화면으로 환산."""
3242
+ count = 0
3243
+ group_hits: dict[str, int] = {}
3244
+ for sid in state.answered:
3245
+ gid = _STEP_TO_GROUP.get(sid)
3246
+ if gid is None:
3247
+ count += 1
3248
+ else:
3249
+ group_hits[gid] = group_hits.get(gid, 0) + 1
3250
+ for hits in group_hits.values():
3251
+ count += math.ceil(hits / GROUP_MAX_TABS)
3252
+ return count
3253
+
3254
+
3255
+ def _sim_answer(prompt: Prompt) -> str:
3256
+ """분모 추정 시뮬레이션의 기본답: pick 은 첫 옵션(추천), text 는 빈 값."""
3257
+ if prompt.kind == "pick" and prompt.options:
3258
+ return prompt.options[0].value
3259
+ return ""
3260
+
3261
+
3262
+ def _sim_advance(state: WizardState, prompt: Prompt) -> None:
3263
+ """기본답으로 한 화면 전진한다. progress 를 재계산하는 submit()/
3264
+ _submit_group() 은 호출하지 않고 step.submit 만 직접 호출해 재귀를 막는다."""
3265
+ try:
3266
+ if prompt.kind == "pick_group":
3267
+ for q in prompt.questions:
3268
+ STEP_BY_ID[q.step].submit(state, _sim_answer(q))
3269
+ for q in prompt.questions:
3270
+ if q.step not in state.answered:
3271
+ state.answered.append(q.step)
3272
+ return
3273
+ STEP_BY_ID[prompt.step].submit(state, _sim_answer(prompt))
3274
+ if prompt.step not in state.answered:
3275
+ state.answered.append(prompt.step)
3276
+ except Exception:
3277
+ # 추천 경로가 막히는 드문 text 분기: answered 마킹만으로 전진시킨다.
3278
+ members = prompt.questions if prompt.kind == "pick_group" else [prompt]
3279
+ for p in members:
3280
+ if p.step not in state.answered:
3281
+ state.answered.append(p.step)
3282
+
3283
+
3284
+ def _remaining_screens(state: WizardState) -> int:
3285
+ """현재 화면부터 confirm(=done 직전)까지 남은 화면 수를 시뮬레이션으로 센다."""
3286
+ sim = copy.deepcopy(state)
3287
+ screens = 0
3288
+ for _ in range(len(STEPS) * 2 + 5): # Edit 루프 등에 대한 상한 가드
3289
+ try:
3290
+ prompt = next_prompt(sim)
3291
+ except Exception:
3292
+ break
3293
+ if prompt.kind in ("done", "aborted"):
3294
+ break
3295
+ screens += 1
3296
+ _sim_advance(sim, prompt)
3297
+ if prompt.step == S_CONFIRM: # confirm 이후는 done — Edit 는 가정하지 않는다
3298
+ break
3299
+ return screens
3300
+
3301
+
3302
+ def _screen_progress(state: WizardState) -> dict[str, int]:
3303
+ passed = _passed_screens(state)
3304
+ total = passed + _remaining_screens(state)
3305
+ index = passed + 1
3306
+ return {"index": index, "total": total, "remaining": max(0, total - index)}
3307
+
3308
+
3309
+ def prompt_payload(state: WizardState, prompt: Prompt) -> dict[str, Any]:
3310
+ """Prompt JSON 에 진행 카운터를 덧붙인다. done/aborted 는 progress 를 생략."""
3311
+ out = prompt.to_json()
3312
+ if prompt.kind not in ("done", "aborted"):
3313
+ out["progress"] = _screen_progress(state)
3314
+ return out
3315
+
3316
+
3238
3317
  def _submit_group(state: WizardState, prompt: Prompt, value: str) -> dict[str, Any]:
3239
3318
  """pick_group 답(JSON 객체)을 각 멤버 submit() 으로 라우팅한다.
3240
3319
 
@@ -3258,7 +3337,7 @@ def _submit_group(state: WizardState, prompt: Prompt, value: str) -> dict[str, A
3258
3337
  if q.step not in state.answered:
3259
3338
  state.answered.append(q.step)
3260
3339
  nxt = next_prompt(state)
3261
- return {"echo": "; ".join(echoes), "next": nxt.to_json()}
3340
+ return {"echo": "; ".join(echoes), "next": prompt_payload(state, nxt)}
3262
3341
 
3263
3342
 
3264
3343
  def submit(state: WizardState, value: str) -> dict[str, Any]:
@@ -3269,7 +3348,7 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
3269
3348
  """
3270
3349
  prompt = next_prompt(state)
3271
3350
  if prompt.kind in ("done", "aborted"):
3272
- return {"echo": "", "next": prompt.to_json()}
3351
+ return {"echo": "", "next": prompt_payload(state, prompt)}
3273
3352
  if prompt.kind == "pick_group":
3274
3353
  return _submit_group(state, prompt, value)
3275
3354
  step = STEP_BY_ID[prompt.step]
@@ -3277,7 +3356,7 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
3277
3356
  if prompt.step not in state.answered:
3278
3357
  state.answered.append(prompt.step)
3279
3358
  nxt = next_prompt(state)
3280
- return {"echo": echo or "", "next": nxt.to_json()}
3359
+ return {"echo": echo or "", "next": prompt_payload(state, nxt)}
3281
3360
 
3282
3361
 
3283
3362
  def render_args(state: WizardState) -> dict[str, str]:
@@ -3471,7 +3550,7 @@ def _cli(argv: list[str]) -> int:
3471
3550
  state.critic = args.critic
3472
3551
  save_state_file(state_path, state)
3473
3552
  first = next_prompt(state)
3474
- print(json.dumps({"ok": True, "next": first.to_json()},
3553
+ print(json.dumps({"ok": True, "next": prompt_payload(state, first)},
3475
3554
  ensure_ascii=False, indent=2))
3476
3555
  return 0
3477
3556
 
@@ -3497,13 +3576,13 @@ def _cli(argv: list[str]) -> int:
3497
3576
  return 2
3498
3577
  try:
3499
3578
  if args.no_submit:
3500
- result = {"echo": "", "next": next_prompt(state).to_json()}
3579
+ result = {"echo": "", "next": prompt_payload(state, next_prompt(state))}
3501
3580
  else:
3502
3581
  result = submit(state, args.answer)
3503
3582
  except WizardError as exc:
3504
3583
  save_state_file(state_path, state)
3505
3584
  print(json.dumps({"ok": False, "error": str(exc),
3506
- "current": next_prompt(state).to_json()},
3585
+ "current": prompt_payload(state, next_prompt(state))},
3507
3586
  ensure_ascii=False, indent=2))
3508
3587
  return 0
3509
3588
  save_state_file(state_path, state)
@@ -28,9 +28,12 @@ Every wizard call returns JSON. The two shapes you'll see:
28
28
 
29
29
  ```json
30
30
  { "ok": true, "echo": "task-group: backend-api",
31
- "next": { "step": "task_id", "kind": "text", "label": "...", "options": [], "echoTemplate": "..." } }
31
+ "next": { "step": "task_id", "kind": "text", "label": "...", "options": [], "echoTemplate": "...",
32
+ "progress": { "index": 5, "total": 11, "remaining": 6 } } }
32
33
  ```
33
34
 
35
+ Every non-terminal `next` carries a `progress` object (`done` / `aborted` omit it). `index` is the 1-based number of the **current screen** (pick_group counts as one screen), `total` is the wizard's estimate of the full screen count, and `remaining` = `total − index`. The total is a forward estimate from the current answers, so it may grow by a few when the user opens a branch (e.g. choosing *Customize* adds the model screen). **Always suffix the rendered prompt with the progress marker** — see Step 3.
36
+
34
37
  ```json
35
38
  { "ok": false, "error": "approved plan has no APPROVED marker: ...",
36
39
  "current": { "step": "approved_plan", "kind": "text", "label": "..." } }
@@ -93,7 +96,7 @@ Output: the same `{ok, next}` JSON described above. The first `next` is always `
93
96
 
94
97
  Repeat until `next.kind == "done"` (or `"aborted"` — terminal cancel, see "How the wizard talks to you"):
95
98
 
96
- 1. **Render** the prompt according to `kind` (and `multi` for pick):
99
+ 1. **Render** the prompt according to `kind` (and `multi` for pick). **Always append the progress marker to the rendered question label** (the `AskUserQuestion` question text, or the `text`-prompt message): suffix `label` with ` (Step <index>/<total> · 남은 <remaining>단계)`, using `next.progress`. When `remaining == 0` (the final `confirm` screen), use `(Step <index>/<total> · 마지막 단계)` instead. Example: `모델을 선택하세요 (Step 8/11 · 남은 3단계)`. Re-prompts after `ok: false` reuse `current.progress` the same way. The progress marker is presentation-only — never send it back to the wizard as part of an answer.
97
100
  - `pick` + `multi: false` → `AskUserQuestion` with `multiSelect: false`, `label`, and `options`. The user's chosen option's `value` is the answer string.
98
101
  - `pick` + `multi: true` → `AskUserQuestion` with `multiSelect: true`, `label`, and `options`. Join the selected `value`s with `,` into a single literal CSV string (e.g. `"claude,codex,antigravity"`) and submit it as a single `--answer "claude,codex,antigravity"`. Empty selection submits `--answer ""` and the wizard re-prompts.
99
102
  - `pick_group` → one `AskUserQuestion` with one question per `questions[]` entry (tab). Map each tab's selected `value` back by `questions[].step`, assemble a JSON object, and submit it as a single literal `--answer '<json>'`.
@@ -207,7 +207,16 @@ button[data-action]:hover { background: color-mix(in srgb, Highlight 20%, Button
207
207
  #plan-approval h2 { margin-top: 0; }
208
208
  #plan-approval label { display: block; margin: 0.4em 0; }
209
209
  #plan-approval select { font: inherit; max-width: 100%; }
210
- .approval-disabled-reason { color: GrayText; margin: 0.4em 0 0; }
210
+ .approval-disabled-reason {
211
+ margin: 0.6em 0 0;
212
+ padding: 0.7em 0.9em;
213
+ border-radius: 4px;
214
+ background: color-mix(in srgb, Highlight 12%, transparent);
215
+ border: 1px solid color-mix(in srgb, Highlight 45%, transparent);
216
+ }
217
+ .approval-disabled-reason p { margin: 0 0 0.5em; }
218
+ .approval-steps { margin: 0.3em 0 0; padding-left: 1.4em; }
219
+ .approval-steps li { margin: 0.25em 0; }
211
220
 
212
221
  @media print {
213
222
  .report-header, .report-footer { position: static; }
@@ -48,6 +48,26 @@
48
48
  }
49
49
  ]
50
50
  }
51
+ ],
52
+ "SubagentStop": [
53
+ {
54
+ "hooks": [
55
+ {
56
+ "type": "command",
57
+ "command": "$HOME/.okstra/bin/okstra-subagent-reclaim.sh"
58
+ }
59
+ ]
60
+ }
61
+ ],
62
+ "TaskCompleted": [
63
+ {
64
+ "hooks": [
65
+ {
66
+ "type": "command",
67
+ "command": "$HOME/.okstra/bin/okstra-subagent-reclaim.sh"
68
+ }
69
+ ]
70
+ }
51
71
  ]
52
72
  }
53
73
  }