okstra 0.42.0 → 0.43.1
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/docs/kr/architecture.md +5 -4
- package/docs/kr/cli.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +4 -1
- package/runtime/agents/workers/codex-worker.md +1 -1
- package/runtime/agents/workers/gemini-worker.md +1 -1
- package/runtime/bin/okstra-codex-exec.sh +51 -46
- package/runtime/bin/okstra-gemini-exec.sh +34 -33
- package/runtime/bin/okstra-spawn-followups.py +325 -0
- package/runtime/bin/okstra-trace-cleanup.sh +111 -69
- package/runtime/prompts/profiles/_common-contract.md +19 -8
- package/runtime/python/okstra_token_usage/collect.py +11 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +2 -0
- package/runtime/skills/okstra-team-contract/SKILL.md +1 -1
- package/runtime/templates/reports/settings.template.json +1 -1
- package/src/install.mjs +1 -0
package/docs/kr/architecture.md
CHANGED
|
@@ -239,6 +239,7 @@ per-process 환경 변수에 task 정체성·경로·workflow 상태를 보관
|
|
|
239
239
|
- standard workflow의 기본 worker role은 `Claude worker`, `Codex worker`, `Report writer worker`이며, `Gemini worker`는 `--workers` 또는 프로필에서 명시할 때만 포함되는 옵션입니다.
|
|
240
240
|
- worker 역할 분담과 최종 판단은 Claude가 task bundle을 읽고 수행합니다.
|
|
241
241
|
- 사용자 홈에 설치된 okstra Claude assets(`~/.claude/skills`, `~/.claude/agents`) 는 Agent Teams 를 우선 시도하고, 팀 구성이 불가능할 때만 sequential/background fallback 을 사용하도록 Claude 를 유도합니다.
|
|
242
|
+
- **팀 lifecycle**: lead 는 Phase 3 에서 `TeamCreate(team_name: "okstra-<task-key>")` 로 팀을 만들고 워커를 그 멤버로 dispatch 합니다. run 종료 시(Phase 7 토큰 집계 **이후**, 자동·무프롬프트) lead 는 팀 config 의 멤버에게 `SendMessage({type: "shutdown_request"})` 로 graceful 종료를 보낸 뒤 `TeamDelete` 로 팀을 해제합니다 — `TeamDelete` 는 active member 가 남아 있으면 실패하므로 종료 확인 후 호출하며, `~/.claude/teams/<team>/`·`~/.claude/tasks/<team>/` 만 지우고 토큰 집계 소스인 `~/.claude/projects/` jsonl 은 보존합니다. teardown 이 없으면 worker teammate 가 FleetView roster 에 계속 누적됩니다 (`prompts/profiles/_common-contract.md` 의 *Run-end team teardown*). no-`team_name` fallback 에서는 팀이 없으므로 silent-skip.
|
|
242
243
|
|
|
243
244
|
## Claude prompt contract
|
|
244
245
|
|
|
@@ -909,10 +910,10 @@ Phase 7 step 1.5 가 final-report MD 한 본을 입력으로 두 view 를 결정
|
|
|
909
910
|
### Live-log mirror (codex / gemini wrapper)
|
|
910
911
|
|
|
911
912
|
- `scripts/okstra-codex-exec.sh`, `scripts/okstra-gemini-exec.sh` 는 dispatch 마다 prompt path 옆에 `<prompt>.log` sidecar 를 만들고 stdout 을 거기로 mirror 합니다 (`tee`, `PIPESTATUS[0]` 로 종료코드 보존). stderr 은 같은 파일에 append (subagent stderr 캡처 contract 보존), 매 dispatch 시 truncate. 호출 subagent 의 `BashOutput` 폴링은 60s 간격이라 long-running run (analysis 의 large-codebase scan, implementation 의 cargo / pytest) 동안 사용자가 stalled state 를 탐지할 수 없는 문제를 해소합니다.
|
|
912
|
-
-
|
|
913
|
-
- **
|
|
914
|
-
- **phase 전환 시 자동 정리 + worker-agent pane 포함**: `okstra-trace-cleanup.sh
|
|
915
|
-
- **Phase 종료 시 사용자 확인**: run 최종 종료 시점(마지막 단계)에 lead 가 `okstra-trace-cleanup.sh --list
|
|
913
|
+
- tmux 가 reachable 한 lead 환경이면 wrapper 가 sibling pane 을 자동 분할해 `tail -F <log-path>` 를 띄웁니다. trace pane title 은 caller (worker) pane title 에 `-tail` 을 붙인 `<cli>-<role>-<pid>-tail` (e.g. `codex-worker-93421-tail`); 동일 시점에 caller (worker) pane title 은 `<cli>-<role>-<pid>` 로 셋팅됩니다. `<pid>` 는 wrapper 자기 자신의 PID 라서 동일 role 의 worker 가 둘 이상 동시에 spawn 돼도 서로 구분되고, 운영자는 `<caller> ↔ <caller>-tail` 로 시각적으로 매핑할 수 있습니다. **caller pane 해석** — Claude Code Bash tool 은 이제 `$TMUX` 와 `$TMUX_PANE` 를 둘 다 환경에서 제거하므로 env 변수에 의존하지 않습니다. wrapper 는 (1) prompt path 로부터 `<RUN_DIR>` (= `dirname(dirname(prompt_path))`, paths.py SSOT) 를 도출하고, (2) lead 가 자기 foreground pane 에서 1회 기록한 `<RUN_DIR>/state/lead-pane.id` 를 읽어 split anchor 로 씁니다 (background dispatch 에서도 신뢰 가능 — active-pane 추정과 달리 사용자가 pane 을 옮겨도 안전). 기록 파일이 없거나 pane 이 stale 이면 `tmux display-message -p '#{pane_id}'` (active pane) 으로 fallback. trace pane split 은 그 caller pane 을 `-t` 로 명시 anchor 합니다. role 은 wrapper 의 5번째 optional positional 인자이며, 누락 시 기본값 `worker`. caller pane title 은 capture 해두고 EXIT trap 에서 복원하므로 dispatch 사이의 stale title 이 남지 않습니다. focus 는 caller pane 으로 복귀하고, CLI 종료 후 pane 은 유지돼 스크롤백 가능. tmux 미reachable, split 실패, 구버전 tmux 등 모든 경로는 silent degrade.
|
|
914
|
+
- **run-scoped 태깅으로 정리**: trace pane 의 `tail -F` 는 tmux 셸의 자식이라 Claude 가 종료돼도 살아남습니다. wrapper 는 spawn 한 pane 을 `tmux set-option -p @okstra_trace_run=<RUN_DIR>` 로 태깅하고, `okstra-trace-cleanup.sh` 는 `tmux list-panes -a` 에서 그 태그로 pane 을 server-wide 발견해 `tmux kill-pane` 합니다. tmux env 변수·pane-id registry 없이 동작하며, run-scoped 태그라 동시에 도는 다른 okstra run 의 trace pane 을 죽이지 않습니다. cleanup 은 두 진입 형태를 가집니다 — lead 가 `--run-dir <RUN_DIR>` 로 호출(해당 run 의 trace + worker-agent pane 정리)하거나, `templates/reports/settings.template.json` 의 `hooks.SessionEnd` 가 `--reap` 로 호출(`$CLAUDE_PROJECT_DIR/.okstra/` 하위 태그를 가진 trace pane 일괄 정리; 단일 run-dir 이 없는 종료 시점용). tmux 가 없거나 stale pane id 인 경우 silent degrade.
|
|
915
|
+
- **phase 전환 시 자동 정리 + worker-agent pane 포함**: `okstra-trace-cleanup.sh --run-dir <RUN_DIR>` 는 태깅된 trace pane 뿐 아니라 dispatch 된 서브에이전트가 점유하는 worker-agent pane(title `claude-worker` / `codex-worker` / `gemini-worker` / `report-writer-worker`)도 lead 세션(`tmux list-panes -s -t <lead-pane>`) 범위에서 title allowlist 로 식별해 닫습니다(worker-agent pane 은 harness 소유라 태깅 불가). 세션 scope 와 lead 자기 pane 제외는 `<RUN_DIR>/state/lead-pane.id` 로 결정되며, lead 자신의 pane 은 title 이 걸려도 절대 죽이지 않습니다. lead 는 새 phase 의 worker 를 dispatch 하기 직전(`PROGRESS: phase-5.5-convergence` / `phase-6-synthesis` 마커 직전) 이 스크립트를 `--run-dir` 로 호출해 이전 phase 의 pane 을 prompt 없이 정리합니다.
|
|
916
|
+
- **Phase 종료 시 사용자 확인**: run 최종 종료 시점(마지막 단계)에 lead 가 `okstra-trace-cleanup.sh --list --run-dir <RUN_DIR>` 로 잔여 okstra pane(worker-agent + trace) 목록을 출력한 뒤 사용자에게 "모두 닫기 / 그대로 두기" 양자택일을 묻고 응답대로 처리합니다 (`prompts/profiles/_common-contract.md` 의 *Phase wrap-up* 항목). `<RUN_DIR>/state/lead-pane.id` 가 비어 있는(=tmux 밖) 환경에서는 단계 자체가 silent-skip. `--list` 모드는 pane 을 죽이지 않고 `<pane_id>\t<pane_title>` 만 출력하므로 사용자가 무엇이 닫힐지 시각적으로 확인할 수 있습니다.
|
|
916
917
|
- 디스크 누적은 `okstra-logs` skill 이 read-only 로 인벤토리 + cleanup 명령을 제안합니다 (실행은 사용자 copy-paste).
|
|
917
918
|
|
|
918
919
|
### Linked-worktree `.git/` write 권한 (codex / gemini)
|
package/docs/kr/cli.md
CHANGED
|
@@ -591,4 +591,4 @@ chmod +x ~/.local/bin/okstra-ctl
|
|
|
591
591
|
|
|
592
592
|
### Live-log sidecar
|
|
593
593
|
|
|
594
|
-
codex / gemini wrapper 는 매 dispatch 마다 `runs/<task-type>/prompts/<worker>-prompt-<phase>-<seq>.log` sidecar 를 만들고 stdout / stderr 를 mirror 합니다. tmux 안에서 lead 를 띄우면 wrapper 가 자동으로 `tail -F` pane 을 분할합니다 (trace pane title: `<cli>-<role>-<pid>-
|
|
594
|
+
codex / gemini wrapper 는 매 dispatch 마다 `runs/<task-type>/prompts/<worker>-prompt-<phase>-<seq>.log` sidecar 를 만들고 stdout / stderr 를 mirror 합니다. tmux 안에서 lead 를 띄우면 wrapper 가 자동으로 `tail -F` pane 을 분할합니다 (trace pane title: `<cli>-<role>-<pid>-tail`, caller (worker) pane title: `<cli>-<role>-<pid>` — wrapper PID 가 동일 role 의 동시 dispatch 를 구분합니다). 분할된 trace pane 은 `@okstra_trace_run=<RUN_DIR>` pane user-option 으로 태깅돼, Claude `/exit` 시 `SessionEnd` 훅이 `okstra-trace-cleanup.sh --reap` 로 (`$CLAUDE_PROJECT_DIR/.okstra/` scope) 자동 정리합니다. 같은 스크립트를 lead 가 `--run-dir <RUN_DIR>` 로 호출하면 그 run 의 trace pane + dispatch 된 worker-agent pane(title `claude-worker` / `codex-worker` / `gemini-worker` / `report-writer-worker`)을 lead 세션 범위에서 함께 정리하며(lead 자신의 pane 은 제외), lead 는 새 phase dispatch 직전 이를 호출해 이전 phase 의 okstra pane 을 자동 정리합니다. 사용량 인벤토리와 `find … -delete` cleanup 명령은 `okstra-logs` skill 이 read-only 로 제안합니다. 자세한 와이어링은 [`docs/kr/architecture.md`](architecture.md) 의 *Live-log mirror* 절 참고.
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
package/runtime/agents/SKILL.md
CHANGED
|
@@ -43,7 +43,7 @@ This SKILL.md is the operating contract and phase index. Detailed procedures liv
|
|
|
43
43
|
| 5. Fallback | Sequential/background dispatch when Teams unavailable | `okstra-team-contract` |
|
|
44
44
|
| 5.5 Convergence | Cross-verify findings across workers | `okstra-convergence` |
|
|
45
45
|
| 6. Synthesis | Dispatch Report writer worker, review draft. **For `implementation-planning`: then run the Phase 6 plan-body verification sub-step (see Phase 6 section below).** | `okstra-report-writer` + `okstra-convergence` (sub-step) |
|
|
46
|
-
| 7. Persist | Run token-usage collector, update manifests | `okstra-report-writer` |
|
|
46
|
+
| 7. Persist | Run token-usage collector, update manifests, then disband the worker team (shutdown teammates + `TeamDelete`, after collection) | `okstra-report-writer` + `_common-contract.md` "Run-end team teardown" |
|
|
47
47
|
|
|
48
48
|
## Core operating contract
|
|
49
49
|
|
|
@@ -94,6 +94,7 @@ Required checkpoints:
|
|
|
94
94
|
- `PROGRESS: phase-5.5-convergence round=<N> queue=<count>` — at the start of each convergence round (Phase 5.5).
|
|
95
95
|
- `PROGRESS: phase-6-synthesis dispatching report-writer-worker` — at the start of Phase 6.
|
|
96
96
|
- `PROGRESS: phase-7-persist updating manifests` — at the start of Phase 7.
|
|
97
|
+
- `PROGRESS: phase-7-teardown disbanding team` — after token-usage collection, immediately before shutting down worker teammates + `TeamDelete` (Teams mode only; see `_common-contract.md` "Run-end team teardown"). Skipped in the no-`team_name` fallback.
|
|
97
98
|
- `PROGRESS: complete final-report=<relative-path>` — final summary line, after all persistence.
|
|
98
99
|
|
|
99
100
|
These lines are the only structured signal the user has during a long run. Do NOT replace them with prose ("Now I'm starting Phase 2..."), do NOT skip a checkpoint because "the previous message already said that", and do NOT batch multiple checkpoints into one. Each line stands alone so the user (or any operator scraping stdout) can timestamp it externally.
|
|
@@ -215,6 +216,8 @@ Use agent and subagent names that map cleanly to the selected worker roles. Do n
|
|
|
215
216
|
|
|
216
217
|
Spawn **analysis workers only** in the same turn (Phase 4 in Teams mode; Phase 5 with `run_in_background: true` and no `team_name` when Teams unavailable). Preserve exact roster, role labels, assigned models from the task bundle.
|
|
217
218
|
|
|
219
|
+
**Agent `name` on dispatch (BLOCKING — token-usage attribution depends on it).** Every analysis-worker `Agent(...)` call MUST set `name: "<workerId>-worker"` — `name: "claude-worker"` / `name: "codex-worker"` / `name: "gemini-worker"` — exactly as the report-writer dispatch sets `name: "report-writer"` ([okstra-report-writer](./skills/okstra-report-writer/SKILL.md)). The Agent harness records this `name` as `agentName` in the subagent session jsonl, and the Phase 7 token collector matches each worker's session by that `agentName` (`okstra_token_usage/collect.py`). A worker dispatched **without** `name` produces a session with no `agentName`; the collector cannot attribute it and records the worker as `source: "unavailable"` even though the session exists and is team-tagged (observed in `dev-9692` error-analysis: `claude`/`codex` workers dispatched without `name` → both `unavailable`, while the named `report-writer` collected normally). Convergence reverify dispatches keep the prefix (`<workerId>-worker-reverify-r<N>`); implementation executor/verifier variants keep `<workerId>-worker` / `<workerId>-executor`.
|
|
220
|
+
|
|
218
221
|
The no-`team_name` fallback (Phase 5) is only legal when team-state's `teamCreate.status` is `"error"` for this run. If `teamCreate` is missing or `attempted: false`, the correct action when an Agent dispatch is rejected for a missing team is to GO BACK to Phase 3 and call `TeamCreate` — never to strip `team_name` and continue.
|
|
219
222
|
|
|
220
223
|
### Errors log path wiring (BLOCKING)
|
|
@@ -30,7 +30,7 @@ You are a Codex worker agent. Your job is to execute the OpenAI Codex CLI and re
|
|
|
30
30
|
$HOME/.okstra/bin/okstra-codex-exec.sh "<absolute-project-root>" "<assigned-model-execution-value>" "<absolute-prompt-history-path>" [<absolute-worktree-path>] [<role>]
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
The fifth argument `<role>` is folded into both the caller (worker) pane title `codex-<role>-<pid>` and the sibling trace-pane title `codex-<role>-<pid>-
|
|
33
|
+
The fifth argument `<role>` is folded into both the caller (worker) pane title `codex-<role>-<pid>` and the sibling trace-pane title `codex-<role>-<pid>-tail` (`<pid>` = the wrapper's PID, present so concurrent dispatches of the same role can be told apart). Pass the literal string `worker` for every dispatch from this subagent. The wrapper defaults to `worker` when the argument is omitted, but pass it explicitly so the dispatch is self-describing.
|
|
34
34
|
|
|
35
35
|
The fourth argument is **mandatory for implementation phase** and optional otherwise. It must be the literal `EXECUTOR_WORKTREE_PATH` recorded in the run context; the wrapper forwards it to codex as `--add-dir`, which grants the codex sandbox write access to the worktree (where all implementation-phase mutations occur). Without it, codex's `workspace-write` sandbox is anchored only at `<project-root>` and rejects every Edit/Write that targets the worktree (EPERM), which is the failure pattern that originally motivated this argument.
|
|
36
36
|
|
|
@@ -30,7 +30,7 @@ You are a Gemini worker agent. Your job is to execute the Google Gemini CLI and
|
|
|
30
30
|
$HOME/.okstra/bin/okstra-gemini-exec.sh "<absolute-project-root>" "<assigned-model-execution-value>" "<absolute-prompt-history-path>" [<absolute-worktree-path>] [<role>]
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
The fifth argument `<role>` is folded into both the caller (worker) pane title `gemini-<role>-<pid>` and the sibling trace-pane title `gemini-<role>-<pid>-
|
|
33
|
+
The fifth argument `<role>` is folded into both the caller (worker) pane title `gemini-<role>-<pid>` and the sibling trace-pane title `gemini-<role>-<pid>-tail` (`<pid>` = the wrapper's PID, present so concurrent dispatches of the same role can be told apart). Pass the literal string `worker` for every dispatch from this subagent. The wrapper defaults to `worker` when the argument is omitted, but pass it explicitly so the dispatch is self-describing.
|
|
34
34
|
|
|
35
35
|
The fourth argument is **mandatory for implementation phase** and optional otherwise. It must be the literal `EXECUTOR_WORKTREE_PATH` recorded in the run context; the wrapper appends it to gemini's `--include-directories` list so the model can both read and operate on the worktree alongside project-root.
|
|
36
36
|
|
|
@@ -187,28 +187,40 @@ python3 "$script_dir/okstra-wrapper-status.py" \
|
|
|
187
187
|
init "$status_path" "$(basename "$0")" "$role" "$$" "$started_ts" "$log_path" \
|
|
188
188
|
>>"$log_path" 2>&1 || true
|
|
189
189
|
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
190
|
+
# Derive the okstra run dir from the prompt path. paths.py is the SSOT:
|
|
191
|
+
# dispatched prompts live at `<RUN_DIR>/prompts/<cli>-worker-prompt<NNN>.md`,
|
|
192
|
+
# so the run dir is two levels up. Used to (a) read the lead pane the lead
|
|
193
|
+
# recorded in its own foreground pane and (b) tag the trace pane so cleanup
|
|
194
|
+
# can find exactly this run's panes without any tmux env var. Empty if the
|
|
195
|
+
# derivation fails — every dependent step below then degrades to a no-op.
|
|
196
|
+
run_dir="$(cd "$(dirname "$prompt_path")/.." 2>/dev/null && pwd -P || true)"
|
|
197
|
+
lead_pane_file="${run_dir:+$run_dir/state/lead-pane.id}"
|
|
198
|
+
|
|
199
|
+
# Resolve the pane to anchor the trace split to. Claude Code's Bash tool now
|
|
200
|
+
# strips BOTH `$TMUX` and `$TMUX_PANE`, and this wrapper frequently runs
|
|
201
|
+
# backgrounded — so the bare active-pane probe can land on whatever pane the
|
|
202
|
+
# user happens to be looking at now, not Claude's. Prefer the lead pane the
|
|
203
|
+
# lead captured ONCE in its own foreground pane (reliable, see
|
|
204
|
+
# `_common-contract.md`); fall back to `$TMUX_PANE`, then the active-pane
|
|
205
|
+
# probe. A stale recorded id (pane since closed) is rejected via a liveness
|
|
206
|
+
# check so we never anchor the split to a dead pane.
|
|
199
207
|
caller_pane="${TMUX_PANE:-}"
|
|
200
|
-
if [[ -z "$caller_pane" && -n "$
|
|
208
|
+
if [[ -z "$caller_pane" && -n "$lead_pane_file" && -r "$lead_pane_file" ]]; then
|
|
209
|
+
cand="$(head -n1 "$lead_pane_file" 2>/dev/null || true)"
|
|
210
|
+
if [[ -n "$cand" ]] && tmux display-message -p -t "$cand" '#{pane_id}' >/dev/null 2>&1; then
|
|
211
|
+
caller_pane="$cand"
|
|
212
|
+
fi
|
|
213
|
+
fi
|
|
214
|
+
if [[ -z "$caller_pane" ]]; then
|
|
201
215
|
caller_pane=$(tmux display-message -p '#{pane_id}' 2>/dev/null || true)
|
|
202
216
|
fi
|
|
203
217
|
|
|
204
218
|
# Pane titles: worker (caller) pane gets `codex-<role>-<pid>`; the sibling
|
|
205
|
-
# trace pane
|
|
206
|
-
#
|
|
207
|
-
# caller
|
|
208
|
-
# worker pane's title is later overwritten by the parent process (e.g.
|
|
209
|
-
# Claude Code's TUI emitting OSC 2 escape sequences on its own pane).
|
|
219
|
+
# trace pane is that same caller title with a `-tail` suffix, so the
|
|
220
|
+
# operator can visually pair `<caller> ↔ <caller>-tail`. The wrapper PID in
|
|
221
|
+
# the caller title disambiguates concurrent dispatches of the same role.
|
|
210
222
|
pane_label="codex-${role}-$$"
|
|
211
|
-
trace_label="${pane_label}-
|
|
223
|
+
trace_label="${pane_label}-tail"
|
|
212
224
|
|
|
213
225
|
# Capture the caller pane's current title so the EXIT trap can restore it
|
|
214
226
|
# once the wrapper returns. Empty when not in tmux or capture fails — the
|
|
@@ -243,42 +255,35 @@ fi
|
|
|
243
255
|
# for the wrapper to exit. This fires in every phase the wrapper is invoked
|
|
244
256
|
# from (analysis, error-analysis, implementation-planning, implementation,
|
|
245
257
|
# …) — long-running codex dispatches are not implementation-specific. The
|
|
246
|
-
# new pane carries the title `codex-<role>-<pid>-
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
# The
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
# refuses to split (size constraints, locked client), or a stale
|
|
258
|
-
# all degrade to "log file is still on disk; the operator can tail
|
|
259
|
-
# manually from any terminal." The wrapper does NOT switch focus to the
|
|
258
|
+
# new pane carries the title `codex-<role>-<pid>-tail` so the operator can
|
|
259
|
+
# pair it with its caller pane (`codex-<role>-<pid>`). The split is
|
|
260
|
+
# explicitly anchored to the caller pane (`-t "$caller_pane"`) to avoid
|
|
261
|
+
# attaching to tmux's idle active pane. `role` is the optional 5th
|
|
262
|
+
# positional arg (defaults to `worker`); callers that dispatch a different
|
|
263
|
+
# role (e.g. `executor`) must pass it explicitly. The `<pid>` suffix is the
|
|
264
|
+
# wrapper's PID and disambiguates concurrent dispatches of the same role.
|
|
265
|
+
# The pane uses `tail -F` (follow-by-name) so it survives any truncation a
|
|
266
|
+
# re-dispatch performs on the same log path. We gate on a resolved
|
|
267
|
+
# `$caller_pane` (non-empty only when tmux is reachable) rather than the
|
|
268
|
+
# now-stripped `$TMUX`. Failures are tolerated silently: no tmux, a tmux
|
|
269
|
+
# that refuses to split (size constraints, locked client), or a stale
|
|
270
|
+
# socket all degrade to "log file is still on disk; the operator can tail
|
|
271
|
+
# it manually from any terminal." The wrapper does NOT switch focus to the
|
|
260
272
|
# new pane — control returns to the caller's pane via `tmux last-pane`.
|
|
261
|
-
if [[ -n "$
|
|
262
|
-
split_args=(-h -P -F '#{pane_id}' -c "$(dirname "$log_path")")
|
|
263
|
-
if [[ -n "$caller_pane" ]]; then
|
|
264
|
-
split_args+=(-t "$caller_pane")
|
|
265
|
-
fi
|
|
273
|
+
if [[ -n "$caller_pane" ]]; then
|
|
274
|
+
split_args=(-h -P -F '#{pane_id}' -c "$(dirname "$log_path")" -t "$caller_pane")
|
|
266
275
|
trace_pane=$(tmux split-window "${split_args[@]}" \
|
|
267
276
|
"tail -F $(printf '%q' "$log_path")" 2>/dev/null || true)
|
|
268
277
|
if [[ -n "$trace_pane" ]]; then
|
|
269
278
|
tmux select-pane -t "$trace_pane" -T "$trace_label" 2>/dev/null || true
|
|
279
|
+
# Tag the spawned pane with THIS run's dir so `okstra-trace-cleanup.sh
|
|
280
|
+
# --run-dir <RUN_DIR>` (see that script + `_common-contract.md`) can find
|
|
281
|
+
# and close exactly this run's trace panes — discovered server-wide by
|
|
282
|
+
# tag, needing no tmux env var, no pane-id registry, and no active-pane
|
|
283
|
+
# assumption. The run-scoped tag also stops concurrent okstra runs from
|
|
284
|
+
# stomping each other's trace panes.
|
|
285
|
+
[[ -n "$run_dir" ]] && tmux set-option -p -t "$trace_pane" @okstra_trace_run "$run_dir" 2>/dev/null || true
|
|
270
286
|
tmux last-pane 2>/dev/null || true
|
|
271
|
-
# Register the spawned pane so the `SessionEnd` hook (see
|
|
272
|
-
# `okstra-trace-cleanup.sh`) can kill it when the caller's Claude
|
|
273
|
-
# session exits. Scope by `$caller_pane` — the pane Claude itself is
|
|
274
|
-
# attached to — so concurrent Claude instances in the same tmux
|
|
275
|
-
# session do not stomp each other's trace panes.
|
|
276
|
-
if [[ -n "$caller_pane" ]]; then
|
|
277
|
-
registry_dir="${TMPDIR:-/tmp}/okstra-trace-panes"
|
|
278
|
-
mkdir -p "$registry_dir" 2>/dev/null || true
|
|
279
|
-
safe_pane="${caller_pane//[^A-Za-z0-9]/_}"
|
|
280
|
-
printf '%s\n' "$trace_pane" >> "$registry_dir/${safe_pane}.list" 2>/dev/null || true
|
|
281
|
-
fi
|
|
282
287
|
fi
|
|
283
288
|
fi
|
|
284
289
|
|
|
@@ -136,24 +136,32 @@ python3 "$script_dir/okstra-wrapper-status.py" \
|
|
|
136
136
|
init "$status_path" "$(basename "$0")" "$role" "$$" "$started_ts" "$log_path" \
|
|
137
137
|
>>"$log_path" 2>&1 || true
|
|
138
138
|
|
|
139
|
-
# Resolve
|
|
140
|
-
# rationale — kept in lock-step:
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
139
|
+
# Resolve the run dir and the trace-split anchor pane. See
|
|
140
|
+
# `okstra-codex-exec.sh` for the full rationale — kept in lock-step: derive
|
|
141
|
+
# `<RUN_DIR>` from the prompt path (paths.py SSOT) to read the lead-recorded
|
|
142
|
+
# pane and to tag the trace pane; prefer that lead pane over the unreliable
|
|
143
|
+
# active-pane probe (this wrapper runs backgrounded and `$TMUX`/`$TMUX_PANE`
|
|
144
|
+
# are stripped).
|
|
145
|
+
run_dir="$(cd "$(dirname "$prompt_path")/.." 2>/dev/null && pwd -P || true)"
|
|
146
|
+
lead_pane_file="${run_dir:+$run_dir/state/lead-pane.id}"
|
|
147
|
+
|
|
144
148
|
caller_pane="${TMUX_PANE:-}"
|
|
145
|
-
if [[ -z "$caller_pane" && -n "$
|
|
149
|
+
if [[ -z "$caller_pane" && -n "$lead_pane_file" && -r "$lead_pane_file" ]]; then
|
|
150
|
+
cand="$(head -n1 "$lead_pane_file" 2>/dev/null || true)"
|
|
151
|
+
if [[ -n "$cand" ]] && tmux display-message -p -t "$cand" '#{pane_id}' >/dev/null 2>&1; then
|
|
152
|
+
caller_pane="$cand"
|
|
153
|
+
fi
|
|
154
|
+
fi
|
|
155
|
+
if [[ -z "$caller_pane" ]]; then
|
|
146
156
|
caller_pane=$(tmux display-message -p '#{pane_id}' 2>/dev/null || true)
|
|
147
157
|
fi
|
|
148
158
|
|
|
149
159
|
# Pane titles: worker (caller) pane gets `gemini-<role>-<pid>`; the sibling
|
|
150
|
-
# trace pane
|
|
151
|
-
#
|
|
152
|
-
# caller
|
|
153
|
-
# worker pane's title is later overwritten by the parent process (e.g.
|
|
154
|
-
# Claude Code's TUI emitting OSC 2 escape sequences on its own pane).
|
|
160
|
+
# trace pane is that same caller title with a `-tail` suffix, so the
|
|
161
|
+
# operator can visually pair `<caller> ↔ <caller>-tail`. The wrapper PID in
|
|
162
|
+
# the caller title disambiguates concurrent dispatches of the same role.
|
|
155
163
|
pane_label="gemini-${role}-$$"
|
|
156
|
-
trace_label="${pane_label}-
|
|
164
|
+
trace_label="${pane_label}-tail"
|
|
157
165
|
|
|
158
166
|
# Capture the caller pane's current title so the EXIT trap can restore it
|
|
159
167
|
# once the wrapper returns. Empty when not in tmux or capture fails — the
|
|
@@ -186,33 +194,26 @@ fi
|
|
|
186
194
|
# When a tmux session is reachable, split a sibling pane tailing the log so
|
|
187
195
|
# the operator can watch progress live. This fires in every phase the
|
|
188
196
|
# wrapper is invoked from — long-running gemini dispatches are not
|
|
189
|
-
# implementation-specific. Title `gemini-<role>-<pid>-
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
# See the codex wrapper for the full design rationale and the
|
|
197
|
+
# implementation-specific. Title `gemini-<role>-<pid>-tail` so the operator
|
|
198
|
+
# can pair it with its caller pane (`gemini-<role>-<pid>`). The split is
|
|
199
|
+
# explicitly anchored to the caller pane to avoid attaching to tmux's idle
|
|
200
|
+
# active pane. `role` is the optional 5th positional arg (defaults to
|
|
201
|
+
# `worker`); callers that dispatch a different role must pass it explicitly.
|
|
202
|
+
# The `<pid>` suffix is the wrapper's PID and disambiguates concurrent
|
|
203
|
+
# dispatches of the same role. We gate on a resolved `$caller_pane`
|
|
204
|
+
# (non-empty only when tmux is reachable) rather than the now-stripped
|
|
205
|
+
# `$TMUX`. See the codex wrapper for the full design rationale and the
|
|
198
206
|
# silent-degrade failure model.
|
|
199
|
-
if [[ -n "$
|
|
200
|
-
split_args=(-h -P -F '#{pane_id}' -c "$(dirname "$log_path")")
|
|
201
|
-
if [[ -n "$caller_pane" ]]; then
|
|
202
|
-
split_args+=(-t "$caller_pane")
|
|
203
|
-
fi
|
|
207
|
+
if [[ -n "$caller_pane" ]]; then
|
|
208
|
+
split_args=(-h -P -F '#{pane_id}' -c "$(dirname "$log_path")" -t "$caller_pane")
|
|
204
209
|
trace_pane=$(tmux split-window "${split_args[@]}" \
|
|
205
210
|
"tail -F $(printf '%q' "$log_path")" 2>/dev/null || true)
|
|
206
211
|
if [[ -n "$trace_pane" ]]; then
|
|
207
212
|
tmux select-pane -t "$trace_pane" -T "$trace_label" 2>/dev/null || true
|
|
213
|
+
# Tag with this run's dir for `okstra-trace-cleanup.sh --run-dir`. See
|
|
214
|
+
# `okstra-codex-exec.sh` for the rationale — kept in lock-step.
|
|
215
|
+
[[ -n "$run_dir" ]] && tmux set-option -p -t "$trace_pane" @okstra_trace_run "$run_dir" 2>/dev/null || true
|
|
208
216
|
tmux last-pane 2>/dev/null || true
|
|
209
|
-
# See `okstra-codex-exec.sh` for the registry rationale — kept in lock-step.
|
|
210
|
-
if [[ -n "$caller_pane" ]]; then
|
|
211
|
-
registry_dir="${TMPDIR:-/tmp}/okstra-trace-panes"
|
|
212
|
-
mkdir -p "$registry_dir" 2>/dev/null || true
|
|
213
|
-
safe_pane="${caller_pane//[^A-Za-z0-9]/_}"
|
|
214
|
-
printf '%s\n' "$trace_pane" >> "$registry_dir/${safe_pane}.list" 2>/dev/null || true
|
|
215
|
-
fi
|
|
216
217
|
fi
|
|
217
218
|
fi
|
|
218
219
|
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""OKSTRA follow-up task spawner.
|
|
3
|
+
|
|
4
|
+
Reads the ``followUpTasks[]`` array from a final-report ``data.json``
|
|
5
|
+
(the JSON SSOT for the final-report markdown) and creates stub task
|
|
6
|
+
directories for rows whose ``autoSpawn`` is ``yes`` AND whose ``origin``
|
|
7
|
+
is not the same-task-key ``phase-continuation`` marker.
|
|
8
|
+
|
|
9
|
+
Idempotent: rows whose target directory already exists are reported as
|
|
10
|
+
``existing`` and skipped. Existing directories are NEVER mutated.
|
|
11
|
+
|
|
12
|
+
Output: writes new directories under
|
|
13
|
+
``<project_root>/.okstra/tasks/<task-group>/<new-task-id>/`` with:
|
|
14
|
+
- ``task-manifest.json`` — minimal manifest (schemaVersion 1.0,
|
|
15
|
+
currentStatus ``todo``, workflow.currentPhase = suggestedTaskType,
|
|
16
|
+
workflow.currentPhaseState ``not-started``, parentTaskKey /
|
|
17
|
+
spawnedFromReport recorded under ``relatedTasks``).
|
|
18
|
+
- ``instruction-set/task-brief.md`` — stub brief naming the parent and
|
|
19
|
+
copying the Reason / Scope cells from the data.json row.
|
|
20
|
+
- ``task-index.md`` — short human-readable summary.
|
|
21
|
+
|
|
22
|
+
The script DOES NOT call the okstra runtime; it produces just enough
|
|
23
|
+
on-disk state for the next user-driven entry — ``/okstra-run
|
|
24
|
+
task-key=<new-key> task-type=<suggested>`` inside a Claude Code session,
|
|
25
|
+
or ``scripts/okstra.sh --task-key <new-key> --task-type <suggested>``
|
|
26
|
+
in a separate terminal — to pick the follow-up up and re-render a fully
|
|
27
|
+
canonical manifest on first execution.
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
python3 scripts/okstra-spawn-followups.py \\
|
|
31
|
+
<final-report-data.json> \\
|
|
32
|
+
--project-root <abs-path> \\
|
|
33
|
+
--task-group <group-slug> \\
|
|
34
|
+
--parent-task-key <parent-task-key> \\
|
|
35
|
+
[--dry-run]
|
|
36
|
+
|
|
37
|
+
Exit codes:
|
|
38
|
+
0 — at least one follow-up evaluated (including ``skipped`` /
|
|
39
|
+
``existing`` only)
|
|
40
|
+
1 — invocation / parsing failure, or any row failed validation
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import datetime as dt
|
|
46
|
+
import json
|
|
47
|
+
import re
|
|
48
|
+
import sys
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
52
|
+
|
|
53
|
+
from okstra_project.dirs import tasks_root # noqa: E402
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
SLUG_RE = re.compile(r"[^a-zA-Z0-9-]+")
|
|
57
|
+
|
|
58
|
+
ALLOWED_TASK_TYPES = {
|
|
59
|
+
"requirements-discovery",
|
|
60
|
+
"error-analysis",
|
|
61
|
+
"implementation-planning",
|
|
62
|
+
"implementation",
|
|
63
|
+
"final-verification",
|
|
64
|
+
"release-handoff",
|
|
65
|
+
}
|
|
66
|
+
ALLOWED_ORIGINS = {
|
|
67
|
+
"phase-continuation",
|
|
68
|
+
"out-of-plan",
|
|
69
|
+
"verifier-concern",
|
|
70
|
+
"scope-boundary",
|
|
71
|
+
"open-question",
|
|
72
|
+
"manual",
|
|
73
|
+
}
|
|
74
|
+
# Origins that point at the SAME task-key (next phase) and therefore
|
|
75
|
+
# must never spawn a new task directory — the user advances via
|
|
76
|
+
# /okstra-run.
|
|
77
|
+
NON_SPAWNING_ORIGINS = {"phase-continuation"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _slugify(value: str) -> str:
|
|
81
|
+
value = value.strip()
|
|
82
|
+
value = SLUG_RE.sub("-", value)
|
|
83
|
+
value = re.sub(r"-+", "-", value)
|
|
84
|
+
return value.strip("-").lower()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _validate_row(row: dict) -> tuple[bool, str]:
|
|
88
|
+
origin = (row.get("origin") or "").strip()
|
|
89
|
+
if origin not in ALLOWED_ORIGINS:
|
|
90
|
+
return False, f"invalid origin: {origin!r}"
|
|
91
|
+
task_type = (row.get("suggestedTaskType") or "").strip()
|
|
92
|
+
if task_type not in ALLOWED_TASK_TYPES:
|
|
93
|
+
return False, f"invalid suggestedTaskType: {task_type!r}"
|
|
94
|
+
if not (row.get("title") or "").strip():
|
|
95
|
+
return False, "title is empty"
|
|
96
|
+
if not (row.get("reason") or "").strip():
|
|
97
|
+
return False, "reason is empty"
|
|
98
|
+
if not (row.get("newTaskId") or "").strip():
|
|
99
|
+
return False, "newTaskId is empty"
|
|
100
|
+
return True, ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _write_manifest(path: Path, payload: dict) -> None:
|
|
104
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
path.write_text(
|
|
106
|
+
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
|
|
107
|
+
encoding="utf-8",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _data_to_report_path(data_path: Path) -> Path:
|
|
112
|
+
"""Derive the markdown sibling path used in spawned manifests for
|
|
113
|
+
the `parentReportPath` field. Falls back to the data.json itself if
|
|
114
|
+
the suffix is not recognised.
|
|
115
|
+
"""
|
|
116
|
+
name = data_path.name
|
|
117
|
+
if name.endswith(".data.json"):
|
|
118
|
+
return data_path.with_name(name[: -len(".data.json")] + ".md")
|
|
119
|
+
return data_path.with_suffix(".md")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _spawn_one(
|
|
123
|
+
*,
|
|
124
|
+
project_root: Path,
|
|
125
|
+
task_group: str,
|
|
126
|
+
parent_task_key: str,
|
|
127
|
+
parent_report_relative: str,
|
|
128
|
+
row: dict,
|
|
129
|
+
dry_run: bool,
|
|
130
|
+
) -> tuple[str, str]:
|
|
131
|
+
"""Returns (status, target_relative_path).
|
|
132
|
+
|
|
133
|
+
status ∈ {created, existing, skipped, invalid}
|
|
134
|
+
"""
|
|
135
|
+
ok, why = _validate_row(row)
|
|
136
|
+
if not ok:
|
|
137
|
+
return ("invalid", why)
|
|
138
|
+
|
|
139
|
+
new_task_id = _slugify(row["newTaskId"])
|
|
140
|
+
if not new_task_id:
|
|
141
|
+
return ("invalid", "newTaskId slug is empty after normalisation")
|
|
142
|
+
|
|
143
|
+
task_root = (
|
|
144
|
+
tasks_root(project_root)
|
|
145
|
+
/ _slugify(task_group)
|
|
146
|
+
/ new_task_id
|
|
147
|
+
)
|
|
148
|
+
rel = task_root.relative_to(project_root).as_posix()
|
|
149
|
+
if task_root.exists():
|
|
150
|
+
return ("existing", rel)
|
|
151
|
+
if dry_run:
|
|
152
|
+
return ("created", rel)
|
|
153
|
+
|
|
154
|
+
suggested = row["suggestedTaskType"].strip()
|
|
155
|
+
title = row["title"].strip()
|
|
156
|
+
scope = (row.get("scope") or "").strip()
|
|
157
|
+
reason = row["reason"].strip()
|
|
158
|
+
origin = row["origin"].strip()
|
|
159
|
+
priority = (row.get("priority") or "P1").strip()
|
|
160
|
+
ticket_id = (row.get("ticketId") or "").strip()
|
|
161
|
+
new_task_key = f"{task_group}/{new_task_id}"
|
|
162
|
+
now = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
163
|
+
|
|
164
|
+
spawned_meta: dict = {
|
|
165
|
+
"parentTaskKey": parent_task_key,
|
|
166
|
+
"parentReportPath": parent_report_relative,
|
|
167
|
+
"origin": origin,
|
|
168
|
+
"rowId": row.get("id", ""),
|
|
169
|
+
"priority": priority,
|
|
170
|
+
"spawnedAt": now,
|
|
171
|
+
}
|
|
172
|
+
if ticket_id:
|
|
173
|
+
spawned_meta["ticketId"] = ticket_id
|
|
174
|
+
|
|
175
|
+
manifest_payload = {
|
|
176
|
+
"schemaVersion": "1.0",
|
|
177
|
+
"taskGroup": task_group,
|
|
178
|
+
"taskId": new_task_id,
|
|
179
|
+
"taskKey": new_task_key,
|
|
180
|
+
"taskGroupPathSegment": _slugify(task_group),
|
|
181
|
+
"taskIdPathSegment": new_task_id,
|
|
182
|
+
"taskType": suggested,
|
|
183
|
+
"workCategory": "unknown",
|
|
184
|
+
"currentStatus": "todo",
|
|
185
|
+
"spawnedFromFollowUp": spawned_meta,
|
|
186
|
+
"relatedTasks": [
|
|
187
|
+
{"taskKey": parent_task_key, "relation": "parent-followup-source"},
|
|
188
|
+
],
|
|
189
|
+
"workflow": {
|
|
190
|
+
"currentPhase": suggested,
|
|
191
|
+
"currentPhaseState": "not-started",
|
|
192
|
+
"nextRecommendedPhase": suggested,
|
|
193
|
+
"phaseStates": {},
|
|
194
|
+
"awaitingApproval": False,
|
|
195
|
+
"routingStatus": "follow-up-spawned",
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
_write_manifest(task_root / "task-manifest.json", manifest_payload)
|
|
199
|
+
|
|
200
|
+
brief_path = task_root / "instruction-set" / "task-brief.md"
|
|
201
|
+
brief_path.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
ticket_line = f"- Ticket ID: `{ticket_id}`\n" if ticket_id else ""
|
|
203
|
+
brief_body = (
|
|
204
|
+
f"# Follow-up Task Brief — {new_task_key}\n\n"
|
|
205
|
+
f"- Spawned from: `{parent_task_key}`\n"
|
|
206
|
+
f"{ticket_line}"
|
|
207
|
+
f"- Origin: `{origin}`\n"
|
|
208
|
+
f"- Source report row: `{row.get('id', '')}` in `{parent_report_relative}`\n"
|
|
209
|
+
f"- Suggested task-type: `{suggested}`\n"
|
|
210
|
+
f"- Priority: `{priority}`\n"
|
|
211
|
+
f"- Spawned at: `{now}`\n\n"
|
|
212
|
+
f"## Title\n\n{title}\n\n"
|
|
213
|
+
f"## Scope (files / areas)\n\n{scope or '_(미지정)_'}\n\n"
|
|
214
|
+
f"## Reason / Why deferred from parent run\n\n{reason}\n\n"
|
|
215
|
+
f"## Next step\n\n"
|
|
216
|
+
f"이 stub은 사용자가 정식 진입할 때 자동 갱신됩니다. 다음 명령 중 하나로 시작하세요:\n\n"
|
|
217
|
+
f"- Claude Code 세션 안: `/okstra-run task-key={new_task_key} task-type={suggested}`\n"
|
|
218
|
+
f"- 별도 터미널: `scripts/okstra.sh --task-key {new_task_key} --task-type {suggested}`\n"
|
|
219
|
+
)
|
|
220
|
+
brief_path.write_text(brief_body, encoding="utf-8")
|
|
221
|
+
|
|
222
|
+
index_path = task_root / "task-index.md"
|
|
223
|
+
index_body = (
|
|
224
|
+
f"# {new_task_key} — Follow-up Task (todo)\n\n"
|
|
225
|
+
f"- Parent: `{parent_task_key}`\n"
|
|
226
|
+
f"{ticket_line}"
|
|
227
|
+
f"- Suggested task-type: `{suggested}`\n"
|
|
228
|
+
f"- Origin: `{origin}`\n"
|
|
229
|
+
f"- Priority: `{priority}`\n"
|
|
230
|
+
f"- Spawned from report: `{parent_report_relative}`\n"
|
|
231
|
+
f"- Stub brief: `instruction-set/task-brief.md`\n"
|
|
232
|
+
f"- Status: `todo`\n\n"
|
|
233
|
+
f"이 task는 자동 생성된 follow-up stub입니다. 정식 진입 시 manifest가 재렌더링됩니다.\n"
|
|
234
|
+
)
|
|
235
|
+
index_path.write_text(index_body, encoding="utf-8")
|
|
236
|
+
|
|
237
|
+
return ("created", rel)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def main(argv: list[str]) -> int:
|
|
241
|
+
parser = argparse.ArgumentParser(
|
|
242
|
+
description="Spawn follow-up task stubs from a final-report data.json.",
|
|
243
|
+
)
|
|
244
|
+
parser.add_argument(
|
|
245
|
+
"data_file",
|
|
246
|
+
type=Path,
|
|
247
|
+
help="Path to the final-report data.json (the JSON SSOT).",
|
|
248
|
+
)
|
|
249
|
+
parser.add_argument("--project-root", type=Path, required=True)
|
|
250
|
+
parser.add_argument(
|
|
251
|
+
"--task-group",
|
|
252
|
+
required=True,
|
|
253
|
+
help="Task-group slug of the parent task.",
|
|
254
|
+
)
|
|
255
|
+
parser.add_argument("--parent-task-key", required=True)
|
|
256
|
+
parser.add_argument(
|
|
257
|
+
"--dry-run",
|
|
258
|
+
action="store_true",
|
|
259
|
+
help="Parse and validate only; do not write files.",
|
|
260
|
+
)
|
|
261
|
+
args = parser.parse_args(argv)
|
|
262
|
+
|
|
263
|
+
if not args.data_file.exists():
|
|
264
|
+
print(f"data.json not found: {args.data_file}", file=sys.stderr)
|
|
265
|
+
return 1
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
data = json.loads(args.data_file.read_text(encoding="utf-8"))
|
|
269
|
+
except json.JSONDecodeError as exc:
|
|
270
|
+
print(f"invalid JSON in {args.data_file}: {exc}", file=sys.stderr)
|
|
271
|
+
return 1
|
|
272
|
+
|
|
273
|
+
rows = data.get("followUpTasks") or []
|
|
274
|
+
if not rows:
|
|
275
|
+
print("followUpTasks is empty — nothing to do.")
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
# Manifests record the markdown sibling rather than data.json so the
|
|
279
|
+
# user-facing report (and not the SSOT) is the cite-able artifact.
|
|
280
|
+
parent_report = _data_to_report_path(args.data_file)
|
|
281
|
+
try:
|
|
282
|
+
parent_report_relative = (
|
|
283
|
+
parent_report.resolve().relative_to(args.project_root.resolve()).as_posix()
|
|
284
|
+
)
|
|
285
|
+
except ValueError:
|
|
286
|
+
parent_report_relative = str(parent_report)
|
|
287
|
+
|
|
288
|
+
results = []
|
|
289
|
+
for row in rows:
|
|
290
|
+
origin = (row.get("origin") or "").strip().lower()
|
|
291
|
+
if origin in NON_SPAWNING_ORIGINS:
|
|
292
|
+
results.append((
|
|
293
|
+
"skipped",
|
|
294
|
+
row.get("newTaskId", ""),
|
|
295
|
+
f"{origin} (advance via /okstra-run, no new task dir)",
|
|
296
|
+
))
|
|
297
|
+
continue
|
|
298
|
+
if (row.get("autoSpawn") or "").strip().lower() != "yes":
|
|
299
|
+
results.append((
|
|
300
|
+
"skipped",
|
|
301
|
+
row.get("newTaskId", ""),
|
|
302
|
+
"autoSpawn != yes",
|
|
303
|
+
))
|
|
304
|
+
continue
|
|
305
|
+
status, info = _spawn_one(
|
|
306
|
+
project_root=args.project_root,
|
|
307
|
+
task_group=args.task_group,
|
|
308
|
+
parent_task_key=args.parent_task_key,
|
|
309
|
+
parent_report_relative=parent_report_relative,
|
|
310
|
+
row=row,
|
|
311
|
+
dry_run=args.dry_run,
|
|
312
|
+
)
|
|
313
|
+
results.append((status, row.get("newTaskId", ""), info))
|
|
314
|
+
|
|
315
|
+
print(f"Follow-up spawn summary ({'dry-run' if args.dry_run else 'live'}):")
|
|
316
|
+
for status, task_id, info in results:
|
|
317
|
+
print(f" - [{status}] {task_id}: {info}")
|
|
318
|
+
|
|
319
|
+
if any(status == "invalid" for status, *_ in results):
|
|
320
|
+
return 1
|
|
321
|
+
return 0
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -1,93 +1,136 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
|
-
# okstra-trace-cleanup.sh —
|
|
4
|
-
# the current Claude Code (lead) session.
|
|
3
|
+
# okstra-trace-cleanup.sh — close tmux panes created during okstra runs.
|
|
5
4
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# `report-writer-worker`). Not registered anywhere by okstra; identified by
|
|
13
|
-
# a title allowlist within the lead's tmux session.
|
|
5
|
+
# Trace panes are `tail -F` siblings spawned by the codex/gemini wrappers
|
|
6
|
+
# (`okstra-codex-exec.sh`, `okstra-gemini-exec.sh`). Each wrapper tags the pane
|
|
7
|
+
# it spawns with a pane-level user option `@okstra_trace_run=<RUN_DIR>`, so the
|
|
8
|
+
# panes are found server-wide by tag — no tmux env var and no pane-id registry
|
|
9
|
+
# are needed, and the run-scoped tag keeps concurrent okstra runs from closing
|
|
10
|
+
# each other's panes.
|
|
14
11
|
#
|
|
15
|
-
#
|
|
16
|
-
# the allowlist. The scan is scoped to the lead's session (`list-panes -s`),
|
|
17
|
-
# never the whole server (`-a`).
|
|
12
|
+
# Two invocation shapes:
|
|
18
13
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
14
|
+
# --run-dir <RUN_DIR> Used by the LEAD between phases and at wrap-up. Closes
|
|
15
|
+
# (a) trace panes tagged with this run's dir and
|
|
16
|
+
# (b) worker-agent panes the harness gives to dispatched
|
|
17
|
+
# subagents (`claude-worker` / `codex-worker` /
|
|
18
|
+
# `gemini-worker` / `report-writer-worker`), identified
|
|
19
|
+
# by a title allowlist scoped to the LEAD's session. The
|
|
20
|
+
# lead pane is read from `<RUN_DIR>/state/lead-pane.id`
|
|
21
|
+
# (recorded once by the lead in its own foreground pane —
|
|
22
|
+
# reliable even though Claude Code's Bash tool strips
|
|
23
|
+
# `$TMUX`/`$TMUX_PANE`); it scopes the title scan and is
|
|
24
|
+
# NEVER killed.
|
|
26
25
|
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
26
|
+
# --reap Used by the `SessionEnd` hook, where no single run-dir
|
|
27
|
+
# applies. Closes every trace pane whose tag points under
|
|
28
|
+
# `$CLAUDE_PROJECT_DIR/.okstra/` (or every tagged trace
|
|
29
|
+
# pane if that env var is unset). Harness-owned
|
|
30
|
+
# worker-agent panes are left to the harness.
|
|
31
|
+
#
|
|
32
|
+
# `--list` (alias `--dry-run`) prints `<pane_id>\t<pane_title>` per pane instead
|
|
33
|
+
# of killing — only meaningful with `--run-dir`.
|
|
34
|
+
#
|
|
35
|
+
# Failures are tolerated silently — a stale pane id, no tmux, or a locked tmux
|
|
36
|
+
# client must never prevent Claude from exiting cleanly.
|
|
29
37
|
|
|
30
38
|
set -u
|
|
31
39
|
|
|
32
|
-
MODE="kill"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
MODE="kill" # kill | list
|
|
41
|
+
REAP=0
|
|
42
|
+
run_dir=""
|
|
43
|
+
while [[ $# -gt 0 ]]; do
|
|
44
|
+
case "$1" in
|
|
45
|
+
--list|--dry-run) MODE="list" ;;
|
|
46
|
+
--reap) REAP=1 ;;
|
|
47
|
+
--run-dir) shift; run_dir="${1-}" ;;
|
|
48
|
+
--run-dir=*) run_dir="${1#--run-dir=}" ;;
|
|
49
|
+
-h|--help)
|
|
50
|
+
cat <<'USAGE'
|
|
51
|
+
usage: okstra-trace-cleanup.sh (--run-dir <RUN_DIR> [--list] | --reap)
|
|
40
52
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
--list print "<pane_id>\t<pane_title>" per okstra pane; no kill.
|
|
53
|
+
--run-dir okstra run directory; closes that run's trace + worker-agent panes.
|
|
54
|
+
--list with --run-dir: print "<pane_id>\t<pane_title>" per pane; no kill.
|
|
44
55
|
--dry-run alias for --list.
|
|
56
|
+
--reap close every okstra trace pane under $CLAUDE_PROJECT_DIR/.okstra
|
|
57
|
+
(SessionEnd hook; no single run-dir applies).
|
|
45
58
|
USAGE
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
esac
|
|
59
|
+
exit 0 ;;
|
|
60
|
+
*)
|
|
61
|
+
printf 'okstra-trace-cleanup.sh: unknown option: %s\n' "$1" >&2
|
|
62
|
+
exit 2 ;;
|
|
63
|
+
esac
|
|
64
|
+
shift
|
|
65
|
+
done
|
|
51
66
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
exit
|
|
67
|
+
if [[ "$REAP" -eq 0 && -z "$run_dir" ]]; then
|
|
68
|
+
printf 'okstra-trace-cleanup.sh: --run-dir <RUN_DIR> (or --reap) is required\n' >&2
|
|
69
|
+
exit 2
|
|
55
70
|
fi
|
|
56
71
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
72
|
+
# Canonicalize paths used in tag string-compares. The wrappers tag panes with
|
|
73
|
+
# `pwd -P` (symlink-resolved), so the scope paths must be resolved the same way
|
|
74
|
+
# — else a symlinked component (e.g. macOS /tmp -> /private/tmp) makes the
|
|
75
|
+
# compare miss. Fall back to the literal value if the dir does not resolve.
|
|
76
|
+
_resolve() { (cd "$1" 2>/dev/null && pwd -P) || printf '%s' "$1"; }
|
|
77
|
+
[[ -n "$run_dir" ]] && run_dir="$(_resolve "$run_dir")"
|
|
78
|
+
project_dir=""
|
|
79
|
+
[[ -n "${CLAUDE_PROJECT_DIR:-}" ]] && project_dir="$(_resolve "$CLAUDE_PROJECT_DIR")"
|
|
80
|
+
|
|
81
|
+
# Lead pane. For a run, prefer the value the lead recorded in its own foreground
|
|
82
|
+
# pane; fall back to the active-pane probe. Rejected if the recorded pane is
|
|
83
|
+
# gone. For --reap there is no run state — probe the active pane, used only to
|
|
84
|
+
# avoid killing whatever pane the reap runs from.
|
|
85
|
+
lead_pane=""
|
|
86
|
+
if [[ "$REAP" -eq 0 ]]; then
|
|
87
|
+
lead_pane_file="$run_dir/state/lead-pane.id"
|
|
88
|
+
[[ -r "$lead_pane_file" ]] && lead_pane="$(head -n1 "$lead_pane_file" 2>/dev/null || true)"
|
|
89
|
+
fi
|
|
90
|
+
if [[ -z "$lead_pane" ]] || ! tmux display-message -p -t "$lead_pane" '#{pane_id}' >/dev/null 2>&1; then
|
|
91
|
+
lead_pane="$(tmux display-message -p '#{pane_id}' 2>/dev/null || true)"
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
# Does a trace pane's tag belong to the set we are closing?
|
|
95
|
+
_tag_in_scope() {
|
|
96
|
+
local tag="$1"
|
|
97
|
+
if [[ "$REAP" -eq 1 ]]; then
|
|
98
|
+
[[ -z "$tag" ]] && return 1
|
|
99
|
+
[[ -n "$project_dir" ]] && { [[ "$tag" == "$project_dir/"* ]]; return; }
|
|
100
|
+
return 0 # no project scope available → reap every tagged trace pane
|
|
101
|
+
fi
|
|
102
|
+
[[ "$tag" == "$run_dir" ]]
|
|
103
|
+
}
|
|
60
104
|
|
|
61
|
-
# Collect okstra pane ids for the lead session: registered trace panes ∪
|
|
62
|
-
# title-allowlisted worker-agent panes, always excluding the lead pane itself.
|
|
63
105
|
collect_okstra_panes() {
|
|
64
106
|
local -a panes=()
|
|
65
|
-
local pid title
|
|
107
|
+
local pid tag title
|
|
108
|
+
|
|
109
|
+
# (1) Trace panes tagged in scope — found server-wide by tag, so no tmux env
|
|
110
|
+
# var or pane-id registry is needed.
|
|
111
|
+
while IFS=$'\t' read -r pid tag; do
|
|
112
|
+
[[ -n "$pid" ]] || continue
|
|
113
|
+
[[ "$pid" == "$lead_pane" ]] && continue
|
|
114
|
+
_tag_in_scope "$tag" && panes+=("$pid")
|
|
115
|
+
done < <(tmux list-panes -a -F '#{pane_id}'$'\t''#{@okstra_trace_run}' 2>/dev/null || true)
|
|
66
116
|
|
|
67
|
-
# (
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
117
|
+
# (2) Title-allowlisted worker-agent panes in the lead's session. Only for a
|
|
118
|
+
# run (reap leaves these harness-owned panes to the harness). `list-panes -s
|
|
119
|
+
# -t <pane>` resolves the session containing that pane, so the scan never
|
|
120
|
+
# reaches other sessions (no `-a`). Skipped when the lead pane is unknown.
|
|
121
|
+
if [[ "$REAP" -eq 0 && -n "$lead_pane" ]]; then
|
|
122
|
+
while IFS=$'\t' read -r pid title; do
|
|
71
123
|
[[ -n "$pid" ]] || continue
|
|
72
|
-
[[ "$pid" == "$
|
|
73
|
-
|
|
74
|
-
|
|
124
|
+
[[ "$pid" == "$lead_pane" ]] && continue
|
|
125
|
+
case "$title" in
|
|
126
|
+
*claude-worker*|*codex-worker*|*gemini-worker*|*report-writer-worker*)
|
|
127
|
+
panes+=("$pid") ;;
|
|
128
|
+
esac
|
|
129
|
+
done < <(tmux list-panes -s -t "$lead_pane" \
|
|
130
|
+
-F '#{pane_id}'$'\t''#{pane_title}' 2>/dev/null || true)
|
|
75
131
|
fi
|
|
76
132
|
|
|
77
|
-
#
|
|
78
|
-
# `list-panes -s -t <pane>` resolves the session containing that pane, so the
|
|
79
|
-
# scan never reaches other sessions (no `-a`).
|
|
80
|
-
while IFS=$'\t' read -r pid title; do
|
|
81
|
-
[[ -n "$pid" ]] || continue
|
|
82
|
-
[[ "$pid" == "$TMUX_PANE" ]] && continue
|
|
83
|
-
case "$title" in
|
|
84
|
-
*claude-worker*|*codex-worker*|*gemini-worker*|*report-writer-worker*)
|
|
85
|
-
panes+=("$pid") ;;
|
|
86
|
-
esac
|
|
87
|
-
done < <(tmux list-panes -s -t "$TMUX_PANE" \
|
|
88
|
-
-F '#{pane_id}'$'\t''#{pane_title}' 2>/dev/null || true)
|
|
89
|
-
|
|
90
|
-
# Dedupe — a live trace pane can match both the registry and the title scan.
|
|
133
|
+
# Dedupe — a live trace pane can match both the tag scan and the title scan.
|
|
91
134
|
if (( ${#panes[@]} )); then
|
|
92
135
|
printf '%s\n' "${panes[@]}" | awk 'NF && !seen[$0]++'
|
|
93
136
|
fi
|
|
@@ -109,5 +152,4 @@ while IFS= read -r pane_id; do
|
|
|
109
152
|
tmux kill-pane -t "$pane_id" 2>/dev/null || true
|
|
110
153
|
done < <(collect_okstra_panes)
|
|
111
154
|
|
|
112
|
-
rm -f "$registry_file" 2>/dev/null || true
|
|
113
155
|
exit 0
|
|
@@ -29,22 +29,33 @@ profile document.
|
|
|
29
29
|
- This rule does NOT relax any phase-specific Forbidden actions list; safety rules in the per-profile document remain in force regardless of the user's authority.
|
|
30
30
|
- Anti-escalation rule (shared):
|
|
31
31
|
- 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
|
+
- Run-start pane recording (shared — runs ONCE at run start, before the FIRST worker dispatch):
|
|
33
|
+
- The wrappers anchor each trace pane to the lead's pane and the cleanup scopes the worker-agent scan to it, but Claude Code's Bash tool strips `$TMUX`/`$TMUX_PANE`, so the lead MUST record its own pane explicitly. Because the lead runs this in its OWN foreground pane, the active pane IS the lead's — reliable, unlike a backgrounded wrapper's later probe.
|
|
34
|
+
- The lead MUST run once, at run start: `mkdir -p "<RUN_DIR>/state" && tmux display-message -p '#{pane_id}' > "<RUN_DIR>/state/lead-pane.id" 2>/dev/null || true` (substitute the run's absolute `RUN_DIR`). Outside tmux this writes nothing and every pane step below silently no-ops — that empty/absent file is the single signal that the lead is not in tmux.
|
|
32
35
|
- Phase-start pane reset (shared — runs BEFORE dispatching each new worker batch):
|
|
33
|
-
- okstra creates two kinds of tmux pane per run: (a) **worker-agent panes** the harness gives to dispatched subagents (titled `claude-worker` / `codex-worker` / `gemini-worker` / `report-writer-worker`), and (b) **trace panes** the codex/gemini wrappers spawn (`<cli>-<role>-<pid>-
|
|
34
|
-
- When
|
|
35
|
-
- This is **automatic and silent** — NO user prompt. Report it in one short line (e.g. `이전 phase okstra pane 3개 정리`) and proceed. It is silent-skipped when
|
|
36
|
+
- okstra creates two kinds of tmux pane per run: (a) **worker-agent panes** the harness gives to dispatched subagents (titled `claude-worker` / `codex-worker` / `gemini-worker` / `report-writer-worker`), and (b) **trace panes** the codex/gemini wrappers spawn (`<cli>-<role>-<pid>-tail`). Both accumulate across internal phases because each new phase dispatches a fresh worker batch and the prior panes are never reclaimed.
|
|
37
|
+
- 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 phase's workers — i.e. just before emitting each `PROGRESS: phase-5.5-convergence round=<N>` marker and just before `PROGRESS: phase-6-synthesis dispatching report-writer-worker`. This closes every prior-phase okstra pane (worker-agent + trace) for this run, while NEVER killing the lead's own pane.
|
|
38
|
+
- This is **automatic and silent** — NO user prompt. Report it in one short line (e.g. `이전 phase okstra pane 3개 정리`) and proceed. It is silent-skipped when the lead is not in tmux; the lead MUST NOT fabricate a synthetic pane list in that case.
|
|
39
|
+
- Run-end team teardown (shared — runs AFTER Phase 7 persistence/token collection, BEFORE the pane disposition step below):
|
|
40
|
+
- The lead created the worker team in Phase 3 (`TeamCreate(team_name: "okstra-<task-key>")`). 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. The lead MUST release them once the run's work is done.
|
|
41
|
+
- This step is **automatic and silent** — NO user prompt (workers are idle sessions that have already delivered their results; there is nothing for the user to preserve). It runs only when team-state's `teamCreate.status == "ok"` (Teams mode was actually used); in the no-`team_name` fallback there is no team to delete, so silent-skip.
|
|
42
|
+
- Sequence (token-usage collection MUST already be complete — `TeamDelete` removes `~/.claude/teams/<team>/` + `~/.claude/tasks/<team>/` but NOT the `~/.claude/projects/` jsonls Phase 7 reads, yet the read MUST precede teardown):
|
|
43
|
+
1. Read `~/.claude/teams/okstra-<task-key>/config.json` and, for every `members` entry whose name is not the lead, `SendMessage(to: <name>, message: { type: "shutdown_request" })` to terminate it gracefully.
|
|
44
|
+
2. Wait for the shutdown confirmations / idle notifications from all addressed teammates.
|
|
45
|
+
3. Call `TeamDelete()`. If it errors with an active-members message, a teammate has not finished shutting down — wait briefly and retry `TeamDelete()` once.
|
|
46
|
+
- Report it in one short line (e.g. `worker 6명 종료 + 팀 해제`) and proceed. Emit `PROGRESS: phase-7-teardown disbanding team` immediately before step 1.
|
|
36
47
|
- Phase wrap-up — okstra pane disposition (shared, MUST be the *last* step before returning control to the user):
|
|
37
|
-
- At run end the only residual okstra panes are the LAST phase's (e.g. the `report-writer-worker` agent pane and any codex/gemini trace pane). `okstra-trace-cleanup.sh --list` returns one tab-separated `<pane_id>\t<pane_title>` line per residual okstra pane (worker-agent + trace) for this
|
|
38
|
-
- When
|
|
48
|
+
- At run end the only residual okstra panes are the LAST phase's (e.g. the `report-writer-worker` agent pane and any codex/gemini 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.
|
|
49
|
+
- 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.
|
|
39
50
|
- If the list is empty, skip the question — there is nothing to ask about (the phase-start resets above usually already cleared prior phases).
|
|
40
51
|
- 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):
|
|
41
52
|
> 현재 phase 종료 시점입니다. 다음 okstra pane 이 열려 있습니다 — 닫을까요?
|
|
42
53
|
> <인용된 `--list` 출력>
|
|
43
54
|
> (예) 모두 닫기 / (아니오) 그대로 두기
|
|
44
|
-
- On `예` / `y` / `close` → run `$HOME/.okstra/bin/okstra-trace-cleanup.sh
|
|
45
|
-
- On `아니오` / `n` / `keep` → leave the panes intact; remind the user that they will be cleaned up automatically when Claude `/exit` fires the `SessionEnd` hook.
|
|
55
|
+
- On `예` / `y` / `close` → run `$HOME/.okstra/bin/okstra-trace-cleanup.sh --run-dir "<RUN_DIR>"` and report the kill count back in one sentence.
|
|
56
|
+
- 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`).
|
|
46
57
|
- 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.
|
|
47
|
-
- This step is mandatory for every phase (`requirements-discovery`, `error-analysis`, `implementation-planning`, `implementation`, `final-verification`, `release-handoff`). It is silent-skipped when
|
|
58
|
+
- 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.
|
|
48
59
|
- Brief handoff contract (shared — applies whenever the run consumes a task brief produced by `okstra-brief`):
|
|
49
60
|
- the brief is a **pre-discovery artifact**: it converts a domain-reporter's words (non-expert *or* developer) into expert-consumable form so this and later phases can run with zero fill-in questions to the operator. The brief is **not** authoritative on solution decisions; it is authoritative on the reporter's intent.
|
|
50
61
|
- **Reporter confirmation precondition (BLOCKING)**: the brief's frontmatter carries `reporter-confirmations: <complete | partial | pending | skipped>` set by `okstra-brief` Step 6.5. Every phase that consumes the brief MUST read this field before doing analysis. The handling matrix is:
|
|
@@ -118,6 +118,14 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
|
|
|
118
118
|
claude_sessions = find_claude_team_sessions(cwd, team_name, lead_sid)
|
|
119
119
|
by_agent: dict[str, list[tuple[str, Path, dict]]] = {}
|
|
120
120
|
lead_path: Path | None = None
|
|
121
|
+
# Team-tagged non-lead sessions that carry no agentName. These are almost
|
|
122
|
+
# always a worker dispatched without the Agent `name` arg (so the harness
|
|
123
|
+
# recorded no agentName) — the session exists and is team-tagged, but there
|
|
124
|
+
# is nothing to match it to a workerId by. Surfacing them in usageSummary
|
|
125
|
+
# gives the "unavailable" worker a visible cause instead of vanishing
|
|
126
|
+
# silently (observed in dev-9692 error-analysis: claude/codex workers
|
|
127
|
+
# dispatched without `name` → both unavailable, report-writer named → fine).
|
|
128
|
+
unattributed_sessions: list[str] = []
|
|
121
129
|
for sid, path in claude_sessions.items():
|
|
122
130
|
if sid == lead_sid:
|
|
123
131
|
lead_path = path
|
|
@@ -126,6 +134,8 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
|
|
|
126
134
|
agent = totals.get("agentName")
|
|
127
135
|
if agent:
|
|
128
136
|
by_agent.setdefault(agent, []).append((sid, path, totals))
|
|
137
|
+
else:
|
|
138
|
+
unattributed_sessions.append(sid)
|
|
129
139
|
|
|
130
140
|
# Lead.
|
|
131
141
|
if lead_path is not None:
|
|
@@ -242,6 +252,7 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
|
|
|
242
252
|
"teamName": team_name,
|
|
243
253
|
"sessionsFound": len(claude_sessions),
|
|
244
254
|
"unmatchedModels": sorted(set(unmatched_models)),
|
|
255
|
+
"unattributedTeamSessions": unattributed_sessions,
|
|
245
256
|
"definitions": {
|
|
246
257
|
"totalTokens": "Sum of input + output + cache_creation + cache_read tokens (raw processed volume; matches Anthropic API breakdown). Cache reads are 95%+ in long sessions.",
|
|
247
258
|
"billableEquivalentTokens": "Tokens normalized to base-input-price units (cache_creation_5m x1.25, cache_creation_1h x2.0, cache_read x0.1, output x5). 5m vs 1h is split from usage.cache_creation when the API breakdown is present; otherwise all cache_creation falls into 5m.",
|
|
@@ -127,6 +127,8 @@ The four steps below MUST execute in this exact order. Reordering them is the re
|
|
|
127
127
|
|
|
128
128
|
The status file is written after step 3 completes.
|
|
129
129
|
|
|
130
|
+
**Run-end team teardown follows this whole sequence.** Token-usage collection (step 1) reads the worker session jsonls, so the lead MUST NOT disband the team until every step above is done. Only then does the lead shut down worker teammates + `TeamDelete` per `_common-contract.md` "Run-end team teardown" (Teams mode only; silent-skip in the no-`team_name` fallback).
|
|
131
|
+
|
|
130
132
|
## Final Report Structure
|
|
131
133
|
|
|
132
134
|
The final report follows the structure encoded in `schemas/final-report-v1.0.schema.json`. The schema is the single source of truth for section names, row shapes, enum values, and task-type-conditional blocks. The Jinja2 template `templates/reports/final-report.template.md` produces the human-readable form from any data.json that validates against the schema. The structure description below is a reading guide for writers; the schema is the binding contract.
|
|
@@ -366,7 +366,7 @@ okstra token-usage /abs/path/to/run/state/team-state-<task-type>-<seq>.json --wr
|
|
|
366
366
|
`okstra token-usage` is a thin Node-side wrapper around the python helper installed at `~/.okstra/bin/okstra-token-usage.py`. Calling the python script directly with `python3 "$HOME/..."` is forbidden — the `$HOME` expansion breaks the literal-token permission match and forces a confirmation prompt every call.
|
|
367
367
|
|
|
368
368
|
The script reads:
|
|
369
|
-
- `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` for the lead and every Claude-side worker (Claude worker, Report writer worker, plus the Claude wrappers around Codex/Gemini workers). Sessions are discovered by `teamName: okstra-<task-id>`, lead is identified by `lead.sessionId`, and other workers are identified by `agentName` (e.g. `claude-worker`, `codex-worker`, `gemini-worker`, `report-writer`).
|
|
369
|
+
- `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` for the lead and every Claude-side worker (Claude worker, Report writer worker, plus the Claude wrappers around Codex/Gemini workers). Sessions are discovered by `teamName: okstra-<task-id>`, lead is identified by `lead.sessionId`, and other workers are identified by `agentName` (e.g. `claude-worker`, `codex-worker`, `gemini-worker`, `report-writer`). **For this `agentName` match to work, Lead MUST set the Agent `name` arg to `<workerId>-worker` on every dispatch** (see [agents SKILL.md Phase 4 — "Agent `name` on dispatch"](../../agents/SKILL.md)); a worker dispatched without `name` carries no `agentName`, so the collector cannot attribute its session and records it `unavailable` (now surfaced as a `usageSummary.unattributedTeamSessions` entry rather than dropped silently).
|
|
370
370
|
- `~/.codex/sessions/Y/M/D/rollout-*.jsonl` for the underlying Codex CLI session (matched by `cwd` and timestamp window of the wrapper subagent). Last `event_msg.token_count.total_token_usage.total_tokens` is the session total.
|
|
371
371
|
- `~/.gemini/tmp/<project>/chats/session-*.json` for the underlying Gemini CLI session. Sum of per-message `tokens.total`.
|
|
372
372
|
|
package/src/install.mjs
CHANGED