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/docs/kr/architecture.md +1 -1
- package/docs/superpowers/plans/2026-06-18-subagent-pane-reclaim.md +483 -0
- package/docs/superpowers/specs/2026-06-18-subagent-pane-reclaim-design.md +150 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-antigravity-exec.sh +1 -0
- package/runtime/bin/okstra-codex-exec.sh +1 -0
- package/runtime/bin/okstra-subagent-reclaim.sh +26 -0
- package/runtime/bin/okstra-trace-cleanup.sh +41 -13
- package/runtime/prompts/profiles/_common-contract.md +7 -6
- package/runtime/python/okstra_ctl/pane_reclaim.py +55 -0
- package/runtime/python/okstra_ctl/report_views.py +60 -15
- package/runtime/python/okstra_ctl/wizard.py +85 -6
- package/runtime/skills/okstra-run/SKILL.md +5 -2
- package/runtime/templates/reports/report.css +10 -1
- package/runtime/templates/reports/settings.template.json +20 -0
- package/src/doctor.mjs +12 -5
- package/src/install.mjs +1 -0
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -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)
|
|
55
|
-
--
|
|
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
|
|
63
|
-
--list
|
|
64
|
-
--dry-run
|
|
65
|
-
--
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
-
|
|
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`).
|
|
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
|
-
|
|
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
|
-
|
|
867
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
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 {
|
|
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
|
}
|