okstra 0.161.0 → 0.162.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/cli.md CHANGED
@@ -19,6 +19,7 @@
19
19
  - [Optional arguments and options](#optional-arguments-and-options)
20
20
  - [`--task-key`](#--task-key)
21
21
  - [`--clarification-response`](#--clarification-response)
22
+ - [`--reverify-scope`](#--reverify-scope)
22
23
  - [`--resume-clarification`](#--resume-clarification)
23
24
  - [`--project-root`](#--project-root)
24
25
  - [`--analysis-target`](#--analysis-target)
@@ -249,6 +250,18 @@ scripts/okstra.sh \
249
250
  --clarification-response .okstra/tasks/tasks/8852/runs/2026-04-29/error-analysis/reports/final-report-2026-04-29_10-15-30.md
250
251
  ```
251
252
 
253
+ ### `--reverify-scope`
254
+
255
+ Pins how much of an `implementation-planning` clarification re-run is verified again. Like `--analysis-target` and `--evidence-inputs`, this is an internal `node bin/okstra render-bundle` input collected by the `/okstra-run` wizard — `scripts/okstra.sh` does not accept it.
256
+
257
+ - Accepted values — exactly three forms; anything else is rejected rather than read as a default:
258
+ - `auto` (or empty, the default) — leave the call to the lead's deterministic `okstra incremental-scope` decision.
259
+ - `full` — re-verify every stage. The lead still records the decision, passing `--full-reason "user requested full re-verification"`.
260
+ - a stage-number list such as `2,3` — the named stages enter the lead's `--impacted` set, unioned with whatever it resolves itself. The CLI's dependency closure and cutoff still apply on top, so a wide enough pin correctly resolves to full.
261
+ - Requires `--clarification-response`, and only `--task-type implementation-planning` accepts a non-empty value. Without a carried report there is no prior Stage Map to narrow against.
262
+ - The wizard asks for this **only when the re-run is narrowable** — that is, when every answered `C-NNN` traces back to a stage in the prior report. When one does not, full is already fixed and the confirmation block's `reverify-scope` line names the unlinked id instead of offering a choice that cannot change the outcome. Direct stage input is validated against the prior report's Stage Map, so a number that plan never had is refused at the prompt.
263
+ - The value reaches the lead as the `REVERIFY_SCOPE_MODE` / `REVERIFY_SCOPE_STAGES` tokens the "Clarification Response Carried In" section of the lead prompt reads. It narrows or widens the inputs to `okstra incremental-scope`; it does not bypass that decision.
264
+
252
265
  ### `--resume-clarification`
253
266
 
254
267
  Immediately edits the final report from the previous `requirements-discovery` or `error-analysis` run and automatically reruns the same phase. You do not need to manage the `--clarification-response` path manually.
@@ -394,9 +407,10 @@ worker roster contains Claude, Codex, or Antigravity.
394
407
 
395
408
  `okstra run` defaults to `auto`. `auto` resolves to one of `claude-code`, `codex`, `antigravity`, or `external` based on the host through `src/lib/runtime-resolver.mjs`. Precedence: explicit runtime > the `OKSTRA_RUNTIME_HOST` environment variable > Claude Code skill handoff > external when tmux is available > fail fast otherwise. The safe fallback never silently selects a runtime different from the user's intent, and the presence of the `agy` binary alone never selects the Antigravity host.
396
409
 
397
- - Inside Claude Code, `/okstra-run` is the front door. Bare `okstra run` cannot invoke host-native Claude workers.
398
- - On a Codex host, the installed `okstra-run` skill is the front door. Bare `okstra run` cannot invoke host-native Codex workers.
399
- - On an Antigravity host, the installed `okstra-run` skill is the front door. Bare `okstra run` cannot invoke host-native Antigravity workers.
410
+ - Inside a supported host, the installed `okstra-run` skill is the front door and reuses the session you are already in.
411
+ - From a terminal, `okstra run <lead>` starts that lead itself: `okstra run` (Claude Code), `okstra run codex`, `okstra run antigravity`. The leading word is an alias for `--lead-runtime`.
412
+ - `okstra run external` does not start a lead it orchestrates an all-CLI run through `okstra team`.
413
+ - Starting a lead whose CLI sandboxes itself prints what is given up and waits for a `y`; `--yes` answers it. Today only Codex asks, because a sandboxed lead can reach neither cmux nor the worker CLIs' own configuration.
400
414
  - In a generic terminal with tmux, `okstra run` orchestrates `render-bundle --lead-runtime external` -> `okstra team dispatch` -> `okstra team await`.
401
415
 
402
416
  `doctor` and `render-bundle` also default to `auto`, but a plain terminal without a host signal must specify a host. `okstra install` and `ensure-installed` are exceptions. Installation places the shared runtime and skill targets based on agent homes rather than the runtime host, so it records the `auto` request and unresolved state in the manifest and continues even without a host signal. Skill copy targets depend on the presence of agent homes and the default Agent target policy, not runtime resolution. `~/.agents/skills/` is always created, and Claude skills/agents are installed when `~/.claude` exists. The host-aware `okstra-run` skill passes the current harness runtime explicitly; Claude-only maintenance skills continue to pass `--runtime claude-code` where that host is part of their contract.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.161.0",
3
+ "version": "0.162.1",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.161.0",
3
- "builtAt": "2026-08-08T16:14:31.788Z",
2
+ "package": "0.162.1",
3
+ "builtAt": "2026-08-08T18:29:02.245Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -59,14 +59,14 @@ resolve_task_root_for_shortcut() {
59
59
  local task_id="$4"
60
60
 
61
61
  local resolved=""
62
- resolved="$(python3 - "$WORKSPACE_ROOT/scripts" "$project_root" "$project_id" "$task_group" "$task_id" <<'PY'
62
+ resolved="$(python3 - "$OKSTRA_PYTHONPATH" "$project_root" "$project_id" "$task_group" "$task_id" <<'PY'
63
63
  import json, os, sys
64
64
  from pathlib import Path
65
65
 
66
66
  # task root 의 slug 경로 구성은 okstra_ctl.paths.task_dir(SSOT) 에 위임한다.
67
67
  # 과거 이 heredoc 은 slugify 와 `.okstra/tasks/<slug>/<slug>` 구조를 자체
68
68
  # 재구현해 규칙 변경 시 silent drift 위험이 있었다. project-resolver.sh 와
69
- # 동일하게 $WORKSPACE_ROOT/scripts 를 sys.path 에 올려 패키지를 import 한다.
69
+ # 동일하게 $OKSTRA_PYTHONPATH 를 sys.path 에 올려 패키지를 import 한다.
70
70
  sys.path.insert(0, sys.argv[1])
71
71
  from okstra_ctl.paths import task_dir
72
72
 
@@ -176,7 +176,7 @@ autofill_from_manifest() {
176
176
  return 0
177
177
  fi
178
178
 
179
- PYTHONPATH="$WORKSPACE_ROOT/scripts:${PYTHONPATH-}" python3 - "$manifest_project_root" "$manifest_task_root" <<'PY' || true
179
+ PYTHONPATH="$OKSTRA_PYTHONPATH:${PYTHONPATH-}" python3 - "$manifest_project_root" "$manifest_task_root" <<'PY' || true
180
180
  import sys
181
181
  from pathlib import Path
182
182
 
@@ -298,7 +298,7 @@ find_latest_final_report() {
298
298
  }
299
299
 
300
300
  local found=""
301
- found="$(PYTHONPATH="$WORKSPACE_ROOT/scripts:${PYTHONPATH-}" python3 - \
301
+ found="$(PYTHONPATH="$OKSTRA_PYTHONPATH:${PYTHONPATH-}" python3 - \
302
302
  "$task_root" "$task_type_filter" <<'PY'
303
303
  import sys
304
304
  from okstra_ctl.paths import RunRef
@@ -397,7 +397,7 @@ PY
397
397
  local resume_extra_args=()
398
398
  while IFS= read -r -d '' _arg; do
399
399
  resume_extra_args+=("$_arg")
400
- done < <(PYTHONPATH="$WORKSPACE_ROOT/scripts:${PYTHONPATH-}" python3 - \
400
+ done < <(PYTHONPATH="$OKSTRA_PYTHONPATH:${PYTHONPATH-}" python3 - \
401
401
  "$PROJECT_ROOT" "$TASK_GROUP" "$TASK_ID" "$resolved_type" <<'PY'
402
402
  import sys
403
403
  from okstra_ctl.run_context import latest_run_inputs
@@ -9,7 +9,7 @@
9
9
  # strict/safe 두 종류를 분리한다.
10
10
  resolve_project_root_strict() {
11
11
  local explicit="${1-}"
12
- python3 - "$WORKSPACE_ROOT/scripts" "$explicit" <<'PY'
12
+ python3 - "$OKSTRA_PYTHONPATH" "$explicit" <<'PY'
13
13
  import os, sys
14
14
  sys.path.insert(0, sys.argv[1])
15
15
  from okstra_project import resolve_project_root, ResolverError
@@ -26,7 +26,7 @@ PY
26
26
  # 실패해도 0 으로 종료하고 stdout 은 빈 문자열(autofill 등 best-effort 경로용).
27
27
  resolve_project_root_safe() {
28
28
  local explicit="${1-}"
29
- python3 - "$WORKSPACE_ROOT/scripts" "$explicit" <<'PY'
29
+ python3 - "$OKSTRA_PYTHONPATH" "$explicit" <<'PY'
30
30
  import os, sys
31
31
  sys.path.insert(0, sys.argv[1])
32
32
  from okstra_project import resolve_project_root, ResolverError
@@ -43,7 +43,7 @@ PY
43
43
  upsert_project_json() {
44
44
  local project_root="$1"
45
45
  local project_id="$2"
46
- python3 - "$WORKSPACE_ROOT/scripts" "$project_root" "$project_id" <<'PY'
46
+ python3 - "$OKSTRA_PYTHONPATH" "$project_root" "$project_id" <<'PY'
47
47
  import sys
48
48
  sys.path.insert(0, sys.argv[1])
49
49
  from pathlib import Path
@@ -37,8 +37,25 @@ source "$SCRIPT_DIR/lib/okstra/interactive.sh"
37
37
  # shellcheck disable=SC1090
38
38
  source "$SCRIPT_DIR/lib/okstra/project-resolver.sh"
39
39
 
40
+ # The python packages live at `scripts/` in the repo and at `lib/python/` once
41
+ # installed, and this script runs from both. Assuming the repo layout made the
42
+ # installed launcher die on its first import; that went unnoticed because
43
+ # in-host runs reach python through the Node CLI instead of through here.
44
+ OKSTRA_PYTHONPATH=""
45
+ for candidate in "scripts" "python" "lib/python"; do
46
+ if [[ -d "$WORKSPACE_ROOT/$candidate/okstra_project" ]]; then
47
+ OKSTRA_PYTHONPATH="$WORKSPACE_ROOT/$candidate"
48
+ break
49
+ fi
50
+ done
51
+ if [[ -z "$OKSTRA_PYTHONPATH" ]]; then
52
+ printf 'okstra: python package not found under %s — run '"'"'okstra install'"'"'\n' \
53
+ "$WORKSPACE_ROOT" >&2
54
+ exit 1
55
+ fi
56
+
40
57
  okstra_py() {
41
- PYTHONPATH="$WORKSPACE_ROOT/scripts:${PYTHONPATH-}" python3 "$@"
58
+ PYTHONPATH="$OKSTRA_PYTHONPATH:${PYTHONPATH-}" python3 "$@"
42
59
  }
43
60
 
44
61
  parse_cli_arguments "$@"
@@ -153,24 +170,60 @@ fi
153
170
  # Display summary lines to user (everything except the machine-readable line).
154
171
  printf '%s\n' "$PREPARE_OUTPUT" | grep -v '^__OKSTRA_LAUNCH__ '
155
172
 
156
- if ! command -v claude >/dev/null 2>&1; then
157
- printf 'claude command not found\n' >&2
158
- exit 1
159
- fi
160
-
161
- # Parse launch JSON and exec claude.
173
+ # Parse launch metadata. Everything provider-specific which executable, which
174
+ # flags, whether a sandbox must be waived — is decided by the model catalog and
175
+ # arrives already assembled, so this wrapper stays a thin adapter.
162
176
  LAUNCH_JSON="$(printf '%s\n' "$PREPARE_OUTPUT" | sed -n 's/^__OKSTRA_LAUNCH__ //p' | tail -n1)"
163
177
  if [[ -z "$LAUNCH_JSON" ]]; then
164
178
  printf 'okstra: prepare_task_bundle did not emit launch metadata\n' >&2
165
179
  exit 1
166
180
  fi
167
181
 
168
- # Read fields via python (jq not assumed available).
169
- read -r CLAUDE_SESSION_ID LEAD_MODEL_EXECUTION_VALUE PROJECT_ROOT_FROM_PY PROMPT_FILE < <(
182
+ read -r LEAD_EXECUTABLE PROJECT_ROOT_FROM_PY < <(
170
183
  okstra_py - "$LAUNCH_JSON" <<'PY'
171
184
  import json, sys
172
185
  d = json.loads(sys.argv[1])
173
- print(d["claudeSessionId"], d["leadModelExecutionValue"], d["projectRoot"], d["promptFile"])
186
+ print(d["leadExecutable"], d["projectRoot"])
187
+ PY
188
+ )
189
+
190
+ if ! command -v "$LEAD_EXECUTABLE" >/dev/null 2>&1; then
191
+ printf '%s command not found\n' "$LEAD_EXECUTABLE" >&2
192
+ exit 1
193
+ fi
194
+
195
+ # Starting some leads means lowering a protection that CLI applies to itself.
196
+ # okstra never waives it silently: the note says what is given up, and nothing
197
+ # starts without an explicit yes.
198
+ SANDBOX_NOTE="$(
199
+ okstra_py - "$LAUNCH_JSON" <<'PY'
200
+ import json, sys
201
+ print(json.loads(sys.argv[1])["sandboxWaiverNote"])
202
+ PY
203
+ )"
204
+ if [[ -n "$SANDBOX_NOTE" ]]; then
205
+ printf '\n%s\n' "$SANDBOX_NOTE" >&2
206
+ if [[ "$ASSUME_YES" == "true" ]]; then
207
+ printf 'Proceeding without the sandbox (--yes).\n\n' >&2
208
+ else
209
+ printf 'Start %s without its sandbox? [y/N] ' "$LEAD_EXECUTABLE" >&2
210
+ read -r SANDBOX_ANSWER < /dev/tty || SANDBOX_ANSWER=""
211
+ if [[ "$SANDBOX_ANSWER" != "y" && "$SANDBOX_ANSWER" != "Y" ]]; then
212
+ printf 'okstra: cancelled. The task bundle is prepared; rerun to resume it.\n' >&2
213
+ exit 1
214
+ fi
215
+ fi
216
+ fi
217
+
218
+ # NUL-separated so a prompt carrying newlines or quotes survives the handoff.
219
+ LAUNCH_ARGV=()
220
+ while IFS= read -r -d '' LAUNCH_ARG; do
221
+ LAUNCH_ARGV+=("$LAUNCH_ARG")
222
+ done < <(
223
+ okstra_py - "$LAUNCH_JSON" <<'PY'
224
+ import json, sys
225
+ for arg in json.loads(sys.argv[1])["launchArgv"]:
226
+ sys.stdout.write(arg + "\0")
174
227
  PY
175
228
  )
176
229
 
@@ -178,8 +231,5 @@ PY
178
231
  # provisions <PROJECT_ROOT>/.claude/settings.local.json as a symlink to
179
232
  # ~/.okstra/templates/settings.local.json, which Claude Code auto-loads
180
233
  # whenever it runs inside that project — no CLI flag required.
181
- PROMPT="$(cat "$PROMPT_FILE")"
182
234
  cd "$PROJECT_ROOT_FROM_PY"
183
- CLAUDE_COMMAND=(claude --model "$LEAD_MODEL_EXECUTION_VALUE" --session-id "$CLAUDE_SESSION_ID")
184
- CLAUDE_COMMAND+=("$PROMPT")
185
- exec "${CLAUDE_COMMAND[@]}"
235
+ exec "${LAUNCH_ARGV[@]}"
@@ -100,6 +100,10 @@ Emit one `PROGRESS: <phase-id> <verb-phrase>` line as plain user-facing text at
100
100
 
101
101
  The **default is full re-verification**. Only narrow this re-run to the impacted stages when the deterministic `okstra incremental-scope` CLI returns `mode == "incremental"`; on any doubt, stay full. This procedure fires ONLY when this run's task-type is `implementation-planning` AND a prior final report exists for this task-key (its data.json at `runs/implementation-planning/reports/final-report-implementation-planning-<prev-seq>.data.json`, where `<prev-seq>` is the most recent prior implementation-planning run's seq). For every other task-type, ignore this block and re-verify normally. This branches on the CLI's `mode` output only — it does NOT re-implement the safety logic in the prompt.
102
102
 
103
+ 0. **Honour the scope the user already pinned (not a judgement — an instruction).** The wizard asks for a re-verification scope whenever this re-run is narrowable, and the answer arrives as two tokens: mode `{{REVERIFY_SCOPE_MODE}}`, stages `{{REVERIFY_SCOPE_STAGES}}`. Apply it before you form your own view:
104
+ - `auto` — the user left the decision to this procedure. Run steps 1–6 exactly as written; nothing is pinned.
105
+ - `full` — the user asked for full re-verification. Still call the CLI once (the record is the audit trail), passing `--full-reason "user requested full re-verification"` and every other CSV empty. Do not argue the user out of it with a narrower `--impacted`: the answer already weighed the cost.
106
+ - `stages` — the user named the stages that must be re-verified. Put **every** number in `{{REVERIFY_SCOPE_STAGES}}` into `--impacted`, unioned with any stage you resolve yourself. Never drop one because your own back-trace did not reach it — the user is telling you about a blast radius the prior report failed to record, which is exactly what the back-trace cannot see. The CLI still applies the dependency closure and the cutoff on top, so a wide enough pin correctly ends in `mode == "full"`.
103
107
  1. **List the answered clarifications (a fact, not a judgement).** Collect the `C-NNN` ids you reconciled as answered in §1, and the IDs of any PREP items changed by the carried response. Pass the ids through `--answered-clarifications`; the CLI resolves which Stage Map stages each one touches by reading the prior run's own links — the `P-*` plan item that carried that `clarificationId`, and the requirement-coverage row blocked on `C-NNN` in either its `status` or its `approvalDisposition` (a `documented-deviation` row records the block in the latter). Do NOT map answers to stage numbers yourself.
104
108
  - An id that traces to no stage in the prior report makes the CLI return `mode == "full"` for the whole re-run. That is the intended outcome: an answer whose blast radius the prior report never recorded must not narrow anything.
105
109
  - **Structural change is your call, and you declare it.** When an answer overturns the selected Option, restructures the stages, or changes the recommended approach, pass `--full-reason "<what changes and how>"`. It forces `mode == "full"` regardless of every other input — the back-trace resolves stages, it cannot judge whether the plan's shape survived. Do NOT express this by leaving the CSVs empty: that produces the same `no impacted stages resolved` reason as a re-run that simply failed to map anything, and the two become indistinguishable in the record. `--impacted` remains available for stage numbers you resolved yourself.
@@ -63,7 +63,7 @@
63
63
  - Phase 5.5 finding convergence runs in **adversarial mode** for this phase (`convergence.adversarial=true`). Verifiers actively try to refute each worker finding (requirement gap / risk / option) by re-inspecting its cited evidence; the burden of proof sits on the claim. See `prompts/lead/convergence.md` §"Adversarial Verification Mode".
64
64
  - §5.5.9 plan-body verification runs with an **adversarial posture** (`prompts/lead/plan-body-verification.md` §"Adversarial plan-body posture"): verifiers open and confirm every cited path / command and put the burden of proof on the plan. The gate threshold is majority-based for kinds `b`/`c`/`e`, but a single `DISAGREE` blocks on its own for the concrete, safety-critical kind `a` (path/symbol mismatch) — and `f` on `P-Req-*` items. `P-Var-*` items are excepted from the kind-`a` exception: a variation-point defect takes a majority. Rollback ordering (`d`) is advisory and never blocks the gate — a rollback is executed by a human, not by okstra's workers or verifiers. A majority also needs ≥2 participating votes, so a lone dissent whose peer returned a non-result does not block on a majority-gated kind (see that contract's §"Adversarial plan-body posture").
65
65
  - **Incremental re-verification scope (clarification re-runs):** when the lead's `okstra incremental-scope` decision is `mode == "incremental"` (procedure in `prompts/launch.template.md` §"Clarification Response Carried In"), workers re-analyze ONLY the stages listed in `reverify_stages` (the downstream closure of the impacted stages). Workers MUST NOT re-open, re-score, or re-judge any stage in `carry_stages` — those stages' prior plan-item verdicts are carried forward verbatim, and a worker never overwrites a carried verdict with its own judgement. When the decision is `mode == "full"` (the default), every stage is re-analyzed as usual.
66
- - **Single incremental-scope decision:** the lead calls `okstra incremental-scope` exactly once for the re-run, passing the answered `C-NNN` ids through `--answered-clarifications`, changed design-preparation IDs through `--prep-items`, and any lead-resolved stage numbers through `--impacted`; the CLI unions all three before applying the existing dependency closure and cutoff. The clarification ids are resolved to stages by the CLI from the prior report's own `planItems[].clarificationId` and `blocked C-NNN` coverage links — the lead does not map answers to stage numbers. An answer that changes the selected option, Stage Map, or recommended approach is not a local impact: pass every CSV empty so the same call returns `mode == "full"`. A clarification id that traces to no stage, unknown PREP IDs, or invalid `stageRefs` also return an explicit full decision instead of being guessed.
66
+ - **Single incremental-scope decision:** the lead calls `okstra incremental-scope` exactly once for the re-run, passing the answered `C-NNN` ids through `--answered-clarifications`, changed design-preparation IDs through `--prep-items`, and any lead-resolved stage numbers through `--impacted`; the CLI unions all three before applying the existing dependency closure and cutoff. The clarification ids are resolved to stages by the CLI from the prior report's own `planItems[].clarificationId` and `blocked C-NNN` coverage links — the lead does not map answers to stage numbers. An answer that changes the selected option, Stage Map, or recommended approach is not a local impact: pass every CSV empty so the same call returns `mode == "full"`. A clarification id that traces to no stage, unknown PREP IDs, or invalid `stageRefs` also return an explicit full decision instead of being guessed. When the user pinned a scope at the wizard (`REVERIFY_SCOPE_MODE` / `REVERIFY_SCOPE_STAGES` in `prompts/launch.template.md` §"Clarification Response Carried In" step 0), that pin is an input to this same single call — `full` supplies the `--full-reason`, and pinned stage numbers join `--impacted` — never a second call or a bypass of the CLI's closure and cutoff.
67
67
  - **Stage-aware carry:** for an incremental decision, pass its `carry_stages` and `reverify_stages` CSVs unchanged to `okstra incremental-carry`. The helper carries the prior whole stage rows and their owned PREP / `P-Prep-*` artifacts; overlap, cross-scope ownership, scope leaks, or canonical conflicts return `CarryError`. On that error, discard the partial merge and run full re-verification.
68
68
  {{INCLUDE:_coverage-critic.md}}
69
69
  - Non-goals:
@@ -398,6 +398,31 @@
398
398
  "latest_report": "clarification: {value} (재사용)"
399
399
  }
400
400
  },
401
+ "reverify_scope_pick": {
402
+ "label": "재검증 범위를 어떻게 할까요? (답변한 항목이 모두 직전 리포트의 stage 에 연결돼 있어 범위를 좁힐 수 있는 상태입니다 — 최종 확정은 run 시점 base-ref 비교)",
403
+ "echo_template": "reverify-scope: {value}",
404
+ "options": {
405
+ "auto": "관련 stage 만 (추천) — 답변이 닿는 stage 와 그 하위만 다시 검증하고 나머지는 직전 판정을 그대로 이월",
406
+ "full": "전체 재검증 — stage 전부를 처음부터 다시 교차검증 (시간은 더 들지만 계획 형태가 바뀌었을 때 안전)",
407
+ "__free_input__": "직접 입력 — 다시 볼 stage 번호를 지정"
408
+ },
409
+ "echo_suffixes": {
410
+ "auto": "reverify-scope: auto (좁힐 수 있으면 좁힘)",
411
+ "full": "reverify-scope: full (전체 재검증)"
412
+ }
413
+ },
414
+ "reverify_scope_stages": {
415
+ "label": "다시 볼 stage 번호를 쉼표로 입력하세요 (예: 2,3 — 직전 리포트 Stage Map 에 있는 번호만 유효. 지정한 stage 의 하위 stage 까지 함께 재검증됩니다. 빈 줄이면 자동 판정)",
416
+ "echo_template": "reverify-scope: stage {value}",
417
+ "echo_suffixes": {
418
+ "auto": "reverify-scope: auto (좁힐 수 있으면 좁힘)"
419
+ },
420
+ "errors": {
421
+ "not_a_number": "stage 번호는 정수여야 합니다: {token}",
422
+ "unknown_stage": "직전 리포트의 Stage Map 에 없는 stage 입니다: {stages} (Stage Map 의 번호: {known})",
423
+ "no_stage_map": "직전 리포트에서 Stage Map 을 읽을 수 없어 stage 번호를 검증할 수 없습니다: {reason}"
424
+ }
425
+ },
401
426
  "pr_template_pick": {
402
427
  "label": "PR 본문 템플릿 경로를 직접 지정할까요?",
403
428
  "echo_template": "pr-template(pick): {value}",
@@ -567,6 +592,8 @@
567
592
  "reverify_scope_incremental": " reverify-scope: incremental 가능 — 답변된 항목이 모두 직전 리포트의 stage 에 연결됨 (최종 확정은 run 시점 base-ref 비교)",
568
593
  "reverify_scope_unlinked": " reverify-scope: full 예상 — {ids} 이(가) 직전 리포트의 어느 stage 에도 연결되지 않아 범위를 좁힐 수 없음",
569
594
  "reverify_scope_full": " reverify-scope: full 예상 — {reason}",
595
+ "reverify_scope_user_full": " reverify-scope: full (사용자 지정 — 전체 재검증)",
596
+ "reverify_scope_user_stages": " reverify-scope: stage {stages} 재검증 지정 (사용자 지정 — 하위 stage 포함, 나머지는 직전 판정 이월)",
570
597
  "stage_whole_task": "전체 task",
571
598
  "handoff_scope_whole_task": "전체 task (whole-task 검증 기반)",
572
599
  "handoff_scope_stage_group": "stage-group ({stages})"
@@ -10,6 +10,7 @@ import json
10
10
  import os
11
11
  import shlex
12
12
  import shutil
13
+ import socket
13
14
  import subprocess
14
15
  from dataclasses import dataclass
15
16
  from pathlib import Path
@@ -44,6 +45,25 @@ LEAD_TARGET_COLUMNS = 80
44
45
  # Sidebar entries are keyed by source so tools do not overwrite each other's.
45
46
  SIDEBAR_SOURCE = "okstra"
46
47
 
48
+ # Why a run that prepare recorded as cmux can no longer see cmux. Kept apart
49
+ # because they call for opposite responses: a sanitized environment or a denied
50
+ # socket means a sandbox stands in the way and the fallback is doomed with it,
51
+ # while a quit app leaves the worker CLIs perfectly able to run.
52
+ LOST_NOTHING = ""
53
+ LOST_ENVIRONMENT = "environment"
54
+ LOST_DENIED = "denied"
55
+ LOST_GONE = "gone"
56
+
57
+ # Verdicts from `socket_reachability`. `denied` is the one that matters: it
58
+ # means a sandbox stands between this process and cmux, and the same sandbox
59
+ # hides the worker CLIs' own config, so falling back is already doomed.
60
+ SOCKET_OK = "ok"
61
+ SOCKET_DENIED = "denied"
62
+ SOCKET_MISSING = "missing"
63
+ SOCKET_UNREACHABLE = "unreachable"
64
+
65
+ SOCKET_PROBE_TIMEOUT_SECONDS = 2
66
+
47
67
 
48
68
  def cmux_cli_path() -> str:
49
69
  """Absolute path to the cmux CLI, or "" when cmux is not installed.
@@ -301,6 +321,50 @@ def capture_surface(surface_uuid: str, *, last_lines: int = 200) -> str:
301
321
  return result.stdout if result.returncode == 0 else ""
302
322
 
303
323
 
324
+ def socket_reachability() -> str:
325
+ """Why this process can or cannot reach cmux, decided at syscall level.
326
+
327
+ Separating a sandbox from a closed app without matching on cmux's error
328
+ text: a denied connect raises PermissionError, an absent socket raises
329
+ FileNotFoundError. The distinction decides whether degrading is worth
330
+ attempting at all.
331
+ """
332
+ path = os.environ.get("CMUX_SOCKET_PATH", "").strip()
333
+ if not path:
334
+ return SOCKET_MISSING
335
+ probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
336
+ try:
337
+ probe.settimeout(SOCKET_PROBE_TIMEOUT_SECONDS)
338
+ probe.connect(path)
339
+ return SOCKET_OK
340
+ except PermissionError:
341
+ return SOCKET_DENIED
342
+ except FileNotFoundError:
343
+ return SOCKET_MISSING
344
+ except OSError:
345
+ return SOCKET_UNREACHABLE
346
+ finally:
347
+ probe.close()
348
+
349
+
350
+ def unreachable_reason() -> str:
351
+ """Why cmux cannot be reached from here, or "" when it can.
352
+
353
+ `environment` is the case a socket probe alone cannot see: a sandbox that
354
+ sanitizes the environment leaves no CMUX_* variables at all, so cmux looks
355
+ identical to a machine that never had it — except this run's manifest says
356
+ prepare reached it minutes ago.
357
+ """
358
+ if not _lead_workspace_env():
359
+ return LOST_ENVIRONMENT
360
+ reachability = socket_reachability()
361
+ if reachability == SOCKET_DENIED:
362
+ return LOST_DENIED
363
+ if reachability != SOCKET_OK:
364
+ return LOST_GONE
365
+ return LOST_NOTHING if resolve_lead_workspace() else LOST_GONE
366
+
367
+
304
368
  def sidebar_log(workspace: str, message: str, *, level: str = "info") -> None:
305
369
  """Append one line to the workspace sidebar's log."""
306
370
  _sidebar_call(
@@ -8,7 +8,6 @@ from __future__ import annotations
8
8
 
9
9
  import argparse
10
10
  import json
11
- import os
12
11
  import subprocess
13
12
  import sys
14
13
  from dataclasses import dataclass
@@ -762,18 +761,7 @@ def _run_cli_wrapper_worker(
762
761
  plan: DispatchPlan,
763
762
  worker: WorkerJob,
764
763
  ) -> subprocess.CompletedProcess[str]:
765
- env = {
766
- **os.environ,
767
- "OKSTRA_WORKER_ID": worker.worker_id,
768
- "OKSTRA_WORKER_RESULT_PATH": str(worker.result_path),
769
- "OKSTRA_WORKER_AUDIT_PATH": str(worker.worker_result_path),
770
- "OKSTRA_RUN_MANIFEST_PATH": str(plan.manifest_path),
771
- }
772
- if worker.worker_id == REPORT_WRITER_WORKER_ID:
773
- env["OKSTRA_REPORT_WRITER_MARKDOWN_PATH"] = str(
774
- _final_report_markdown_path(worker.result_path)
775
- )
776
- return subprocess.run(worker.command, cwd=plan.project_root, env=env, text=True)
764
+ return subprocess.run(worker.command, cwd=plan.project_root, text=True)
777
765
 
778
766
 
779
767
  def _post_process_report_writer_result(
@@ -2,7 +2,6 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
- import os
6
5
  import subprocess
7
6
  import time
8
7
  from dataclasses import dataclass
@@ -445,6 +444,7 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
445
444
  def _start_cmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
446
445
  workspace = cmux.resolve_lead_workspace()
447
446
  if not workspace:
447
+ _refuse_when_cmux_is_walled_off()
448
448
  return _run_cli_wrapper(plan, job, BACKEND_CMUX_PANE)
449
449
  try:
450
450
  surface_id = cmux.spawn_worker_surface(
@@ -477,17 +477,35 @@ def _start_tmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
477
477
  return WorkerHandle(job, pane_id, None, status_path_for_prompt(job.prompt_path), "")
478
478
 
479
479
 
480
+ def _refuse_when_cmux_is_walled_off() -> None:
481
+ """Stop rather than degrade when a sandbox stands between okstra and cmux.
482
+
483
+ prepare records `cmux-pane` only after reaching cmux, so losing it by
484
+ dispatch time means something came between. When the socket is present but
485
+ connecting is denied, that something is a sandbox around the lead — and the
486
+ same sandbox hides the worker CLIs' own config, so the fallback this would
487
+ otherwise take is already dead. Degrading here spends every worker's retry
488
+ budget on the same wall and leaves the user with three failures and no
489
+ explanation, which is exactly what it looks like when nothing happens.
490
+ """
491
+ reason = cmux.unreachable_reason()
492
+ if reason not in (cmux.LOST_ENVIRONMENT, cmux.LOST_DENIED):
493
+ return
494
+ observed = (
495
+ "this process has no cmux environment left — a sandbox sanitized it"
496
+ if reason == cmux.LOST_ENVIRONMENT
497
+ else "cmux is running but this process may not connect to its socket"
498
+ )
499
+ raise DispatchError(
500
+ f"this run was prepared for cmux panes, but {observed}. The same "
501
+ "sandbox blocks worker CLIs from their own config, so falling back to "
502
+ "blocking workers would fail too. Relaunch the lead outside a sandbox "
503
+ "(codex: `codex -s danger-full-access`)."
504
+ )
505
+
506
+
480
507
  def _run_cli_wrapper(plan: DispatchPlan, job: WorkerJob, degraded_from: str) -> WorkerHandle:
481
- env = {
482
- **os.environ,
483
- "OKSTRA_WORKER_ID": job.worker_id,
484
- "OKSTRA_WORKER_RESULT_PATH": str(job.result_path),
485
- "OKSTRA_WORKER_AUDIT_PATH": str(job.worker_result_path),
486
- "OKSTRA_RUN_MANIFEST_PATH": str(plan.manifest_path),
487
- }
488
- if job.worker_id == REPORT_WRITER_WORKER_ID:
489
- env["OKSTRA_REPORT_WRITER_MARKDOWN_PATH"] = str(_final_report_markdown_path(job.result_path))
490
- completed = subprocess.run(job.command, cwd=plan.project_root, env=env, text=True)
508
+ completed = subprocess.run(job.command, cwd=plan.project_root, text=True)
491
509
  return WorkerHandle(job, "", completed, status_path_for_prompt(job.prompt_path), degraded_from)
492
510
 
493
511
 
@@ -42,6 +42,43 @@ class IncrementalDecision:
42
42
  reason: str
43
43
 
44
44
 
45
+ @dataclass(frozen=True)
46
+ class UserReverifyScope:
47
+ """What the user answered at the wizard's re-verification-scope step.
48
+
49
+ The lead still runs `okstra incremental-scope`; this only says which of the
50
+ CLI's inputs the user pinned. `auto` pins nothing.
51
+ """
52
+ mode: str # "auto" | "full" | "stages"
53
+ stages: list[int]
54
+
55
+
56
+ class ReverifyScopeError(ValueError):
57
+ """The `--reverify-scope` value is not one of the three accepted forms."""
58
+
59
+
60
+ def parse_user_reverify_scope(raw: str) -> UserReverifyScope:
61
+ """`--reverify-scope` → the user's pinned scope.
62
+
63
+ Accepts exactly `""` / `auto`, `full`, or a comma-separated stage list.
64
+ Anything else raises rather than degrading to `auto`: a typo silently read
65
+ as "let the lead decide" would drop a full-re-verification request the user
66
+ made on purpose, and the run would look like it honoured it.
67
+ """
68
+ value = (raw or "").strip()
69
+ if not value or value == "auto":
70
+ return UserReverifyScope("auto", [])
71
+ if value == "full":
72
+ return UserReverifyScope("full", [])
73
+ tokens = [token.strip() for token in value.split(",") if token.strip()]
74
+ if not tokens or not all(token.isdigit() for token in tokens):
75
+ raise ReverifyScopeError(
76
+ f"--reverify-scope must be 'auto', 'full', or a stage-number list "
77
+ f"(e.g. '2,3'); got {raw!r}"
78
+ )
79
+ return UserReverifyScope("stages", sorted({int(token) for token in tokens}))
80
+
81
+
45
82
  def _parse_depends_on(cell: str) -> list[int]:
46
83
  text = (cell or "").strip()
47
84
  if not text or text == "(none)":
@@ -94,6 +94,26 @@ ANALYSIS_ROLES = frozenset({"analyser", "critic"})
94
94
  IMPLEMENTATION_ROLES = frozenset({"executor", "verifier"})
95
95
 
96
96
 
97
+ @dataclass(frozen=True)
98
+ class LeadLaunchSpec:
99
+ """How to start this provider's CLI as an interactive lead session.
100
+
101
+ The worker wrappers cannot serve here: they run one non-interactive turn and
102
+ exit, while a lead owns the session the user talks to. Empty `prompt_flag`
103
+ means the CLI takes the prompt positionally; empty `session_id_flag` means it
104
+ has no resumable session id and resumes from artifacts instead.
105
+ """
106
+
107
+ executable: str
108
+ model_flag: str
109
+ prompt_flag: str = ""
110
+ session_id_flag: str = ""
111
+ # Flags that lift the CLI's own sandbox, and what the user is agreeing to by
112
+ # accepting them. Empty when the CLI does not sandbox itself by default.
113
+ sandbox_waiver: tuple[str, ...] = ()
114
+ sandbox_waiver_note: str = ""
115
+
116
+
97
117
  @dataclass(frozen=True)
98
118
  class ProviderSpec:
99
119
  provider: str
@@ -102,6 +122,7 @@ class ProviderSpec:
102
122
  default_models: Mapping[str, str]
103
123
  wrapper: str
104
124
  supported_roles: frozenset[str]
125
+ lead_launch: Optional[LeadLaunchSpec] = None
105
126
 
106
127
 
107
128
  PROVIDERS = {
@@ -121,6 +142,11 @@ PROVIDERS = {
121
142
  supported_roles=frozenset(
122
143
  {"lead", "analyser", "critic", "executor", "verifier", "report-writer"}
123
144
  ),
145
+ lead_launch=LeadLaunchSpec(
146
+ executable="claude",
147
+ model_flag="--model",
148
+ session_id_flag="--session-id",
149
+ ),
124
150
  ),
125
151
  "antigravity": ProviderSpec(
126
152
  provider="antigravity",
@@ -133,6 +159,13 @@ PROVIDERS = {
133
159
  supported_roles=frozenset(
134
160
  {"lead", "analyser", "critic", "executor", "verifier"}
135
161
  ),
162
+ lead_launch=LeadLaunchSpec(
163
+ executable="agy",
164
+ model_flag="--model",
165
+ # Without this the prompt runs once and the session ends; okstra
166
+ # needs the lead to stay and drive the remaining phases.
167
+ prompt_flag="--prompt-interactive",
168
+ ),
136
169
  ),
137
170
  "codex": ProviderSpec(
138
171
  provider="codex",
@@ -145,6 +178,20 @@ PROVIDERS = {
145
178
  supported_roles=frozenset(
146
179
  {"lead", "analyser", "critic", "executor", "verifier", "report-writer"}
147
180
  ),
181
+ lead_launch=LeadLaunchSpec(
182
+ executable="codex",
183
+ model_flag="-m",
184
+ # A codex session sandboxes itself, and everything it spawns inherits
185
+ # that — okstra's dispatch and every worker CLI with it. Measured:
186
+ # the cmux socket returns EPERM and each worker dies unable to write
187
+ # its own config. A sandboxed lead cannot run okstra at all.
188
+ sandbox_waiver=("-s", "danger-full-access"),
189
+ sandbox_waiver_note=(
190
+ "codex will start without its filesystem and network sandbox, "
191
+ "which okstra needs so the lead can reach cmux and start worker "
192
+ "CLIs."
193
+ ),
194
+ ),
148
195
  ),
149
196
  "grok": ProviderSpec(
150
197
  provider="grok",
@@ -190,6 +237,46 @@ class UnknownProviderError(ValueError):
190
237
  """Raised when a requested provider is absent from the registry."""
191
238
 
192
239
 
240
+ def lead_launch_spec(provider: str) -> LeadLaunchSpec:
241
+ """The launch spec for a provider that can lead, or an error naming why not."""
242
+ spec = provider_spec(provider)
243
+ if spec.lead_launch is None:
244
+ raise UnknownProviderError(
245
+ f"provider {provider!r} cannot act as a lead: no launch spec"
246
+ )
247
+ return spec.lead_launch
248
+
249
+
250
+ def lead_launch_argv(
251
+ provider: str,
252
+ *,
253
+ model: str,
254
+ prompt: str,
255
+ session_id: str = "",
256
+ waive_sandbox: bool = True,
257
+ ) -> list[str]:
258
+ """The argv that starts this provider's CLI as the lead for one run.
259
+
260
+ The sandbox waiver is applied by default because a sandboxed lead cannot run
261
+ okstra at all — it reaches neither cmux nor the worker CLIs' own config. The
262
+ caller still owns telling the user and collecting their answer; passing
263
+ False produces the un-waived argv so a declined confirmation can show what
264
+ would otherwise have run.
265
+ """
266
+ launch = lead_launch_spec(provider)
267
+ argv = [launch.executable]
268
+ if waive_sandbox:
269
+ argv.extend(launch.sandbox_waiver)
270
+ if model:
271
+ argv.extend([launch.model_flag, model])
272
+ if session_id and launch.session_id_flag:
273
+ argv.extend([launch.session_id_flag, session_id])
274
+ if launch.prompt_flag:
275
+ argv.append(launch.prompt_flag)
276
+ argv.append(prompt)
277
+ return argv
278
+
279
+
193
280
  def provider_spec(provider: str) -> ProviderSpec:
194
281
  """Return one registered provider or fail before assignment/dispatch."""
195
282
  normalized = (provider or "").strip().lower()
@@ -46,6 +46,7 @@ from .clarification_items import (
46
46
  scan_approval_gate,
47
47
  )
48
48
  from .error_report import prior_run_error_digest
49
+ from .incremental_scope import ReverifyScopeError, parse_user_reverify_scope
49
50
  from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
50
51
  from .material import (
51
52
  build_analysis_material,
@@ -65,6 +66,8 @@ from .model_discovery import normalize_execution_for_dispatch
65
66
  from .models import (
66
67
  ModelAssignment,
67
68
  default_model,
69
+ lead_launch_argv,
70
+ lead_launch_spec,
68
71
  provider_default_model,
69
72
  provider_ids,
70
73
  provider_spec,
@@ -373,6 +376,10 @@ class PrepareInputs:
373
376
  # 별개 채널이다.
374
377
  stages: str = ""
375
378
  clarification_response_path: str = "" # absolute or empty
379
+ # implementation-planning 전용: 사용자가 고른 이번 재실행의 재검증 범위.
380
+ # "" / "auto" = 리드의 `okstra incremental-scope` 판정에 맡김, "full" =
381
+ # 전체 재검증 강제, "<stage csv>" = 그 stage 들을 impacted 로 지정.
382
+ reverify_scope: str = ""
376
383
  # release-handoff 전용: PR 본문 템플릿 1회성 override. 빈 문자열이면
377
384
  # project.json → global config → 스킬 디폴트 순으로 해석된다.
378
385
  pr_template_path: str = ""
@@ -1016,9 +1023,35 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
1016
1023
  raise PrepareError(
1017
1024
  f"clarification response file not found: {inp.clarification_response_path}"
1018
1025
  )
1026
+ _validate_reverify_scope(inp)
1019
1027
  return ctx_stage_map
1020
1028
 
1021
1029
 
1030
+ def _validate_reverify_scope(inp: PrepareInputs) -> None:
1031
+ """A pinned re-verification scope is only actionable on a planning re-run.
1032
+
1033
+ Every other phase renders the tokens too (the template always reads them),
1034
+ but nothing consumes them there — so a value outside the one phase that
1035
+ acts on it is a caller mistake, not a preference to honour silently.
1036
+ """
1037
+ if not (inp.reverify_scope or "").strip():
1038
+ return
1039
+ if inp.task_type != "implementation-planning":
1040
+ raise PrepareError(
1041
+ "--reverify-scope is only meaningful with --task-type "
1042
+ f"implementation-planning; got {inp.task_type}"
1043
+ )
1044
+ if not inp.clarification_response_path:
1045
+ raise PrepareError(
1046
+ "--reverify-scope needs --clarification-response: there is no prior "
1047
+ "report to narrow re-verification against"
1048
+ )
1049
+ try:
1050
+ parse_user_reverify_scope(inp.reverify_scope)
1051
+ except ReverifyScopeError as exc:
1052
+ raise PrepareError(str(exc)) from exc
1053
+
1054
+
1022
1055
  def _prepare_implementation_approved_plan(inp: PrepareInputs) -> list:
1023
1056
  """Apply approved-plan inputs only after canonical brief preflight succeeds."""
1024
1057
  if inp.approve_plan_ack or inp.implementation_option:
@@ -2276,6 +2309,21 @@ def _related_tasks_ctx(ctx: dict, inp: PrepareInputs) -> dict[str, str]:
2276
2309
  }
2277
2310
 
2278
2311
 
2312
+ def _reverify_scope_ctx(raw: str) -> dict[str, str]:
2313
+ """Render tokens for the re-verification scope the user pinned.
2314
+
2315
+ Always emitted: the lead prompt reads both tokens unconditionally, and an
2316
+ absent one is a render failure rather than a silent `auto`.
2317
+ """
2318
+ scope = parse_user_reverify_scope(raw)
2319
+ return {
2320
+ "REVERIFY_SCOPE_MODE": scope.mode,
2321
+ "REVERIFY_SCOPE_STAGES": (
2322
+ ",".join(str(num) for num in scope.stages) or "(none)"
2323
+ ),
2324
+ }
2325
+
2326
+
2279
2327
  def _model_ctx(models: "_ModelBindings") -> dict[str, str]:
2280
2328
  """Render tokens for every model binding this run resolved."""
2281
2329
  return {
@@ -2577,6 +2625,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2577
2625
  "CLAUDE_SESSION_ID": claude_session_id,
2578
2626
  "CLARIFICATION_RESPONSE_PATH": inp.clarification_response_path,
2579
2627
  "CLARIFICATION_RESPONSE_RELATIVE_PATH": clarification_relative,
2628
+ **_reverify_scope_ctx(inp.reverify_scope),
2580
2629
  "BRIEF_FILE_PATH": str(inp.brief_path),
2581
2630
  "BRIEF_RELATIVE_PATH": brief_relative,
2582
2631
  **_model_ctx(models),
@@ -2761,6 +2810,17 @@ def main(argv: list[str]) -> int:
2761
2810
  ),
2762
2811
  )
2763
2812
  p.add_argument("--clarification-response", default="", dest="clarification_response_path")
2813
+ p.add_argument(
2814
+ "--reverify-scope",
2815
+ default="",
2816
+ dest="reverify_scope",
2817
+ help=(
2818
+ "implementation-planning 재실행 전용. 사용자가 고른 재검증 범위. "
2819
+ "'' / 'auto' = 리드의 incremental-scope 판정에 맡김(기본), "
2820
+ "'full' = 전체 재검증 강제, '<stage csv>' (예: '2,3') = 그 stage 를 "
2821
+ "impacted 로 지정."
2822
+ ),
2823
+ )
2764
2824
  p.add_argument(
2765
2825
  "--pr-template-path",
2766
2826
  default="",
@@ -2870,6 +2930,7 @@ def main(argv: list[str]) -> int:
2870
2930
  stage=args.stage,
2871
2931
  stages=args.stages,
2872
2932
  clarification_response_path=clarification_abs,
2933
+ reverify_scope=args.reverify_scope,
2873
2934
  pr_template_path=args.pr_template_path,
2874
2935
  render_only=args.render_only,
2875
2936
  approve_plan_ack=args.approve_plan_ack,
@@ -2903,19 +2964,38 @@ def main(argv: list[str]) -> int:
2903
2964
  else:
2904
2965
  print(f"okstra current run dir: {ctx['RUN_DIR']}")
2905
2966
  print(f"final report path: {ctx['FINAL_REPORT_PATH']}")
2967
+ lead_runtime_name = ctx.get("LEAD_RUNTIME", "claude-code")
2968
+ lead_provider = lead_runtime_info(lead_runtime_name).agent
2969
+ launch = lead_launch_spec(lead_provider)
2906
2970
  print(f"lead model: {ctx['LEAD_MODEL']}")
2907
2971
  print(f"claude session id: {ctx['CLAUDE_SESSION_ID']}")
2908
2972
  print(f"resume command file: {ctx['CLAUDE_RESUME_COMMAND_PATH']}")
2909
- print("launch mode: interactive Claude handoff")
2910
- print(f"claude working directory: {ctx['PROJECT_ROOT']}")
2973
+ print(f"launch mode: interactive {launch.executable} handoff")
2974
+ print(f"lead working directory: {ctx['PROJECT_ROOT']}")
2911
2975
  print()
2912
- # In non-render-only mode emit a small JSON the bash wrapper can parse
2913
- # to build the `claude` exec command. Wrapper exec's; we don't.
2976
+ # In non-render-only mode emit the JSON a front end needs to exec the
2977
+ # lead. The argv is assembled here rather than in the caller so provider
2978
+ # launch knowledge stays in the catalog and every front end — the bash
2979
+ # wrapper and the Node CLI — starts the lead identically.
2980
+ prompt_file = Path(ctx["INSTRUCTION_SET_PATH"]) / "lead-execution-prompt.md"
2914
2981
  machine = {
2915
- "claudeSessionId": ctx["CLAUDE_SESSION_ID"],
2982
+ "leadRuntime": lead_runtime_name,
2983
+ "leadProvider": lead_provider,
2984
+ "leadExecutable": launch.executable,
2985
+ "leadSessionId": ctx["CLAUDE_SESSION_ID"],
2916
2986
  "leadModelExecutionValue": ctx["LEAD_MODEL_EXECUTION_VALUE"],
2917
2987
  "projectRoot": ctx["PROJECT_ROOT"],
2918
- "promptFile": str(Path(ctx["INSTRUCTION_SET_PATH"]) / "lead-execution-prompt.md"),
2988
+ "promptFile": str(prompt_file),
2989
+ # Non-empty only when starting this lead lowers a protection the
2990
+ # user should agree to first. The front end asks; okstra does not
2991
+ # waive it silently.
2992
+ "sandboxWaiverNote": launch.sandbox_waiver_note,
2993
+ "launchArgv": lead_launch_argv(
2994
+ lead_provider,
2995
+ model=ctx["LEAD_MODEL_EXECUTION_VALUE"],
2996
+ session_id=ctx["CLAUDE_SESSION_ID"],
2997
+ prompt=prompt_file.read_text(encoding="utf-8"),
2998
+ ),
2919
2999
  }
2920
3000
  print(f"__OKSTRA_LAUNCH__ {json.dumps(machine)}")
2921
3001
  return 0
@@ -56,7 +56,10 @@ from okstra_ctl.clarification_items import (
56
56
  sidecar_answers,
57
57
  user_response_sidecars,
58
58
  )
59
- from okstra_ctl.incremental_scope import preview_link_availability_for_report
59
+ from okstra_ctl.incremental_scope import (
60
+ parse_stage_graph,
61
+ preview_link_availability_for_report,
62
+ )
60
63
  from okstra_ctl.design_prep import (
61
64
  DesignPrepError,
62
65
  load_design_prep_items,
@@ -342,6 +345,8 @@ S_RELATED_TASKS_PICK = "related_tasks_pick"
342
345
  S_RELATED_TASKS = "related_tasks"
343
346
  S_CLARIFICATION_PICK = "clarification_pick"
344
347
  S_CLARIFICATION = "clarification"
348
+ S_REVERIFY_SCOPE_PICK = "reverify_scope_pick"
349
+ S_REVERIFY_SCOPE_STAGES = "reverify_scope_stages"
345
350
  S_PR_TEMPLATE_PICK = "pr_template_pick"
346
351
  S_PR_TEMPLATE = "pr_template"
347
352
  S_PR_TEMPLATE_SCOPE = "pr_template_scope"
@@ -473,6 +478,10 @@ class WizardState:
473
478
  clarification_response_path: str = ""
474
479
  clarification_pending_text: bool = False
475
480
  last_final_report_cached: str = ""
481
+ # "" | "auto" | "full" | "<stage csv>" — 사용자가 고른 이번 재실행의 재검증
482
+ # 범위. implementation-planning 재실행에서 좁힐 여지가 있을 때만 채워진다.
483
+ reverify_scope: str = ""
484
+ reverify_scope_pending_text: bool = False
476
485
  pr_template_path: str = ""
477
486
  pr_template_pending_text: bool = False
478
487
  pr_template_scope: str = "" # "once" | "project" | "global"
@@ -3002,6 +3011,128 @@ def _submit_clarification_pick(state: WizardState, value: str) -> Optional[str]:
3002
3011
  return _submit_optional_cached_pick(state, value, _CLARIFICATION_PICK_SPEC)
3003
3012
 
3004
3013
 
3014
+ def _carried_planning_report(state: WizardState) -> Optional[Path]:
3015
+ """이번 재실행이 이어받는 직전 implementation-planning 리포트 (없으면 None)."""
3016
+ if state.task_type != "implementation-planning":
3017
+ return None
3018
+ if not state.clarification_response_path or not state.project_root:
3019
+ return None
3020
+ report = _resolve_path(
3021
+ state.clarification_response_path, Path(state.project_root)
3022
+ )
3023
+ return report if report.is_file() else None
3024
+
3025
+
3026
+ def _reverify_scope_preview(state: WizardState) -> Optional[dict]:
3027
+ """답변된 id 가 직전 리포트의 stage 로 되짚어지는지 — 범위 판정의 앞 절반."""
3028
+ report = _carried_planning_report(state)
3029
+ if report is None:
3030
+ return None
3031
+ return preview_link_availability_for_report(
3032
+ report, set(sidecar_answers(report))
3033
+ )
3034
+
3035
+
3036
+ def _reverify_scope_pick_required(state: WizardState) -> bool:
3037
+ """좁힐 여지가 있는 재실행에서만 범위를 묻는다.
3038
+
3039
+ 링크가 끊겨 full 이 이미 확정된 재실행에서 물으면 어떤 답도 판정을 바꾸지
3040
+ 못한다 — 고를 수 없는 선택지를 내미는 화면이 하나 느는 것뿐이다. 그 경우
3041
+ 확인 블록의 `reverify-scope: full 예상` 줄이 이유까지 같이 알려준다.
3042
+ """
3043
+ preview = _reverify_scope_preview(state)
3044
+ return preview is not None and not preview["wouldForceFull"]
3045
+
3046
+
3047
+ def _reverify_scope_step_pending(state: WizardState) -> bool:
3048
+ """범위 질문이 아직 안 끝났는가 — confirm 진입을 막는 게이트."""
3049
+ if state.reverify_scope_pending_text:
3050
+ return True
3051
+ return _reverify_scope_pick_required(state) and not state.reverify_scope
3052
+
3053
+
3054
+ def _prior_stage_numbers(state: WizardState) -> set[int]:
3055
+ """직전 리포트 Stage Map 의 stage 번호. 읽을 수 없으면 WizardError."""
3056
+ t = _p(state.workspace_root, "reverify_scope_stages")
3057
+ report = _carried_planning_report(state)
3058
+ if report is None:
3059
+ raise WizardError(
3060
+ t["errors"]["no_stage_map"].format(reason="carried report not found")
3061
+ )
3062
+ data_path = final_report_data_path(report)
3063
+ try:
3064
+ data = json.loads(data_path.read_text(encoding="utf-8"))
3065
+ stages = {num for num, _ in parse_stage_graph(data)}
3066
+ except (OSError, ValueError, KeyError, TypeError) as exc:
3067
+ raise WizardError(
3068
+ t["errors"]["no_stage_map"].format(reason=str(exc))
3069
+ ) from exc
3070
+ if not stages:
3071
+ raise WizardError(
3072
+ t["errors"]["no_stage_map"].format(reason="stage map is empty")
3073
+ )
3074
+ return stages
3075
+
3076
+
3077
+ def _build_reverify_scope_pick(state: WizardState) -> Prompt:
3078
+ t = _p(state.workspace_root, "reverify_scope_pick")
3079
+ opts = t["options"]
3080
+ return Prompt(
3081
+ step=S_REVERIFY_SCOPE_PICK, kind="pick", label=t["label"],
3082
+ options=[
3083
+ _opt("auto", opts["auto"]),
3084
+ _opt("full", opts["full"]),
3085
+ _opt(PICK_TYPE_CUSTOM, opts[PICK_TYPE_CUSTOM]),
3086
+ ],
3087
+ echo_template=t["echo_template"])
3088
+
3089
+
3090
+ def _submit_reverify_scope_pick(state: WizardState, value: str) -> Optional[str]:
3091
+ t = _p(state.workspace_root, "reverify_scope_pick")
3092
+ picked = value.strip().lower()
3093
+ if picked == PICK_TYPE_CUSTOM:
3094
+ state.reverify_scope = ""
3095
+ state.reverify_scope_pending_text = True
3096
+ return "reverify-scope: 직접 입력"
3097
+ if picked not in ("auto", "full"):
3098
+ raise WizardError(
3099
+ f"expected 'auto' / 'full' / {PICK_TYPE_CUSTOM!r}, got: {value!r}"
3100
+ )
3101
+ state.reverify_scope = picked
3102
+ state.reverify_scope_pending_text = False
3103
+ return t["echo_suffixes"][picked]
3104
+
3105
+
3106
+ def _build_reverify_scope_stages(state: WizardState) -> Prompt:
3107
+ t = _p(state.workspace_root, "reverify_scope_stages")
3108
+ return Prompt(
3109
+ step=S_REVERIFY_SCOPE_STAGES, kind="text", label=t["label"],
3110
+ echo_template=t["echo_template"])
3111
+
3112
+
3113
+ def _submit_reverify_scope_stages(state: WizardState, value: str) -> Optional[str]:
3114
+ t = _p(state.workspace_root, "reverify_scope_stages")
3115
+ tokens = [token.strip() for token in value.split(",") if token.strip()]
3116
+ if not tokens:
3117
+ state.reverify_scope = "auto"
3118
+ state.reverify_scope_pending_text = False
3119
+ return t["echo_suffixes"]["auto"]
3120
+ for token in tokens:
3121
+ if not token.isdigit():
3122
+ raise WizardError(t["errors"]["not_a_number"].format(token=token))
3123
+ known = _prior_stage_numbers(state)
3124
+ picked = sorted({int(token) for token in tokens})
3125
+ unknown = [num for num in picked if num not in known]
3126
+ if unknown:
3127
+ raise WizardError(t["errors"]["unknown_stage"].format(
3128
+ stages=", ".join(str(num) for num in unknown),
3129
+ known=", ".join(str(num) for num in sorted(known)),
3130
+ ))
3131
+ state.reverify_scope = ",".join(str(num) for num in picked)
3132
+ state.reverify_scope_pending_text = False
3133
+ return t["echo_template"].format(value=state.reverify_scope)
3134
+
3135
+
3005
3136
  def _suggest_project_pr_template(state: WizardState) -> str:
3006
3137
  """project.json 의 prTemplatePath 필드를 읽어 경로 문자열로 반환.
3007
3138
 
@@ -4232,14 +4363,32 @@ STEPS: list[Step] = [
4232
4363
  and S_PR_TEMPLATE_SCOPE not in s.answered),
4233
4364
  build=_build_pr_template_scope, submit=_submit_pr_template_scope,
4234
4365
  owns=("pr_template_scope",)),
4366
+ # 재검증 범위는 clarification-response 가 정해진 뒤에야 판정할 수 있고,
4367
+ # 확인 블록은 full 재검증 비용을 물기 전 마지막 되돌림 지점이다. 그래서
4368
+ # 이 질문은 confirm 바로 앞에 선다.
4369
+ Step(S_REVERIFY_SCOPE_PICK,
4370
+ applies=lambda s: (_ready_for_confirm(s)
4371
+ and not s.reverify_scope_pending_text
4372
+ and not s.reverify_scope
4373
+ and _reverify_scope_pick_required(s)),
4374
+ build=_build_reverify_scope_pick, submit=_submit_reverify_scope_pick,
4375
+ owns=("reverify_scope", "reverify_scope_pending_text")),
4376
+ Step(S_REVERIFY_SCOPE_STAGES,
4377
+ applies=lambda s: (s.reverify_scope_pending_text
4378
+ and S_REVERIFY_SCOPE_STAGES not in s.answered),
4379
+ build=_build_reverify_scope_stages,
4380
+ submit=_submit_reverify_scope_stages,
4381
+ owns=("reverify_scope", "reverify_scope_pending_text")),
4235
4382
  Step(S_FIX_CYCLE_CONFIRM,
4236
4383
  applies=lambda s: (_ready_for_confirm(s)
4384
+ and not _reverify_scope_step_pending(s)
4237
4385
  and _fix_cycle_confirm_required(s)
4238
4386
  and not s.fix_cycle),
4239
4387
  build=_build_fix_cycle_confirm, submit=_submit_fix_cycle_confirm,
4240
4388
  owns=("fix_cycle",)),
4241
4389
  Step(S_CONFIRM,
4242
4390
  applies=lambda s: (_ready_for_confirm(s)
4391
+ and not _reverify_scope_step_pending(s)
4243
4392
  and (not _fix_cycle_confirm_required(s)
4244
4393
  or bool(s.fix_cycle))
4245
4394
  and s.confirmed is None),
@@ -4357,6 +4506,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
4357
4506
  "directive_pending_text": False,
4358
4507
  "related_tasks_raw": "", "related_tasks_pending_text": False,
4359
4508
  "clarification_response_path": "", "clarification_pending_text": False,
4509
+ "reverify_scope": "", "reverify_scope_pending_text": False,
4360
4510
  "pr_template_path": "", "pr_template_pending_text": False,
4361
4511
  "pr_template_scope": "",
4362
4512
  "fix_cycle": "",
@@ -4650,31 +4800,34 @@ def render_args(state: WizardState) -> dict[str, str]:
4650
4800
  "report-writer-model": state.report_writer_model,
4651
4801
  "related-tasks": state.related_tasks_raw,
4652
4802
  "clarification-response": state.clarification_response_path,
4803
+ "reverify-scope": (
4804
+ state.reverify_scope
4805
+ if state.task_type == "implementation-planning" else ""
4806
+ ),
4653
4807
  "pr-template-path": pr_template,
4654
4808
  "fix-cycle": state.fix_cycle,
4655
4809
  }
4656
4810
 
4657
4811
 
4658
4812
  def _reverify_scope_line(state: WizardState) -> Optional[str]:
4659
- """이번 clarification 재실행이 좁혀질지 — 확인 단계에서 미리 보여주는 줄.
4660
-
4661
- 판정의 절반(답변된 id stage 되짚어지는지)은 base SHA 없이 결정되고
4662
- 직전 리포트만 있으면 이미 정해져 있다. 그런데 지금까지는 `okstra recap
4663
- assemble` 따로 돌려야만 보였고, run 시작된 뒤에 full 로 밝혀지면 두
4664
- 시간을 물린 뒤였다. 확인 단계는 전에 되돌릴 있는 마지막 지점이다.
4813
+ """이번 clarification 재실행이 좁혀질지 — 확인 단계에서 보여주는 줄.
4814
+
4815
+ 사용자가 범위를 직접 골랐으면 선택을 찍는다. 고르지 않았으면(또는 좁힐
4816
+ 없어 질문 자체가 경우) 예측을 찍는다. 예측의 절반(답변된 id 가
4817
+ stage 되짚어지는지)은 base SHA 없이 직전 리포트만으로 이미 정해져 있는데,
4818
+ 지금까지는 `okstra recap assemble` 따로 돌려야만 보였고 run 시작된
4819
+ full 로 밝혀지면 두 시간을 물린 뒤였다. 확인 단계는 그 전에 되돌릴 수 있는
4820
+ 마지막 지점이다.
4665
4821
  """
4666
- if state.task_type != "implementation-planning":
4667
- return None
4668
- if not state.clarification_response_path or not state.project_root:
4822
+ preview = _reverify_scope_preview(state)
4823
+ if preview is None:
4669
4824
  return None
4670
- report = _resolve_path(
4671
- state.clarification_response_path, Path(state.project_root)
4672
- )
4673
- if not report.is_file():
4674
- return None
4675
- preview = preview_link_availability_for_report(
4676
- report, set(sidecar_answers(report))
4677
- )
4825
+ if state.reverify_scope == "full":
4826
+ return _msg(state.workspace_root, "confirmation",
4827
+ "reverify_scope_user_full")
4828
+ if state.reverify_scope and state.reverify_scope != "auto":
4829
+ return _msg(state.workspace_root, "confirmation",
4830
+ "reverify_scope_user_stages", stages=state.reverify_scope)
4678
4831
  if not preview["wouldForceFull"]:
4679
4832
  return _msg(state.workspace_root, "confirmation",
4680
4833
  "reverify_scope_incremental")
@@ -142,6 +142,7 @@ That is the entire interactive flow. The wizard handles:
142
142
  - `release-handoff`-only sub-flow: after the approved plan auto-resolves, a `handoff_stage_pick` multi-select — choose an eligible stage bundle (stage-group) or the whole task (when an accepted whole-task verification report exists); the result goes out as render-args' `stages` key (csv, empty when whole-task),
143
143
  - `Use defaults / Customize` branch with profile-aware worker/model questions,
144
144
  - **resume-clarification (in-session equivalent)** — there is no separate mode or flag matching the shell's `okstra.sh --resume-clarification`; two steps of the standard flow carry out its substance. (1) `reuse_previous` (yes/no to reuse the previous run's settings — in `requirements-discovery` / `error-analysis` / `implementation-planning`, only when prior run-inputs exist): YES prefills workers·model·directive·related-tasks at once. (2) `clarification_pick`: if the **task-type's own** previous `final-report` exists it is auto-recommended as the carry-in input (falling back to the newest by mtime across all phases when absent), and the same run's `user-responses/` sidecar (answers the user filled in) is attached alongside. The chosen path is passed to prepare as `--clarification-response` — the user makes the sidecar via the report's `Export user response`, places it in `runs/<task-type>/user-responses/`, and re-runs the same phase,
145
+ - **re-verification scope (`reverify_scope_pick`, `implementation-planning` clarification re-runs only)** — asked right before `confirm`, and **only when the re-run is narrowable** (every answered `C-NNN` traces back to a stage in the prior report). 3 options: `auto` (recommended — leave it to the lead's `okstra incremental-scope` decision) / `full` (re-verify every stage) / Enter directly (a stage-number CSV, validated against the prior report's Stage Map). The answer goes out as `--reverify-scope` and reaches the lead prompt as the `REVERIFY_SCOPE_MODE` / `REVERIFY_SCOPE_STAGES` tokens; it shapes that CLI's inputs rather than replacing the decision. When the re-run is not narrowable the step does not appear — full is already fixed, and the confirmation block's `reverify-scope` line says which answered id broke the link,
145
146
  - `release-handoff` PR template override + persist scope,
146
147
  - final `Proceed / Edit` confirmation; on `Edit` the wizard asks which step to rewind to and clears every later answer.
147
148
 
@@ -1,3 +1,7 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
1
5
  import { runEnsureInstalled } from "../lifecycle/install.mjs";
2
6
  import { runPythonModule } from "../../lib/python-helper.mjs";
3
7
  import { resolvePaths } from "../../lib/paths.mjs";
@@ -6,13 +10,31 @@ import { resolveRuntime } from "../../lib/runtime-resolver.mjs";
6
10
  const USAGE = `okstra run — host-aware okstra execution front door
7
11
 
8
12
  Usage:
9
- okstra run --project-root <dir> --project-id <id> --task-group <tg> \\
10
- --task-id <tid> --task-type <type> [--lead-runtime auto|claude-code|codex|antigravity|external]
11
-
12
- Host-native Claude Code, Codex, and Antigravity execution use the okstra-run
13
- skill front door. Bare 'okstra run' orchestrates only external/tmux all-CLI execution.
13
+ okstra run [claude|codex|antigravity|external] --project-root <dir> \\
14
+ --project-id <id> --task-group <tg> --task-id <tid> --task-type <type> \\
15
+ [--lead-runtime auto|claude-code|codex|antigravity|external] [--yes]
16
+
17
+ The leading word selects the lead and is an alias for --lead-runtime, so
18
+ 'okstra run codex' starts a Codex lead. Naming a lead starts that CLI with the
19
+ prepared prompt; 'external' instead orchestrates an all-CLI run through okstra
20
+ team. Starting a lead whose CLI sandboxes itself asks for confirmation first,
21
+ which --yes answers.
14
22
  `;
15
23
 
24
+ // Leads okstra can start itself. `external` is not one: it names a harness
25
+ // okstra does not own, so those runs are orchestrated rather than launched.
26
+ const LAUNCHABLE_RUNTIMES = new Set(["claude-code", "codex", "antigravity"]);
27
+
28
+ // The leading word users actually type. `claude` is the provider's name while
29
+ // the runtime is `claude-code`; the rest coincide.
30
+ const RUNTIME_ALIASES = new Map([
31
+ ["claude", "claude-code"],
32
+ ["claude-code", "claude-code"],
33
+ ["codex", "codex"],
34
+ ["antigravity", "antigravity"],
35
+ ["external", "external"],
36
+ ]);
37
+
16
38
  function flagValue(args, flag) {
17
39
  const equals = args.find((arg) => arg.startsWith(`${flag}=`));
18
40
  if (equals) return equals.slice(flag.length + 1);
@@ -20,14 +42,21 @@ function flagValue(args, flag) {
20
42
  return index >= 0 ? args[index + 1] : "";
21
43
  }
22
44
 
45
+ export function positionalRuntime(args) {
46
+ const first = args[0];
47
+ if (!first || first.startsWith("-")) return "";
48
+ return RUNTIME_ALIASES.get(first) || "";
49
+ }
50
+
23
51
  export function requestedRuntimeFromArgs(args) {
24
- return flagValue(args, "--lead-runtime") || "auto";
52
+ return flagValue(args, "--lead-runtime") || positionalRuntime(args) || "auto";
25
53
  }
26
54
 
27
- export function withoutLeadRuntimeFlags(args) {
55
+ export function withoutRuntimeSelection(args) {
56
+ const rest = positionalRuntime(args) ? args.slice(1) : args;
28
57
  const out = [];
29
- for (let i = 0; i < args.length; i += 1) {
30
- const arg = args[i];
58
+ for (let i = 0; i < rest.length; i += 1) {
59
+ const arg = rest[i];
31
60
  if (arg === "--lead-runtime") {
32
61
  i += 1;
33
62
  continue;
@@ -53,24 +82,17 @@ function renderArgs(args, resolution) {
53
82
  "--lead-runtime", resolution.resolvedRuntime,
54
83
  "--lead-runtime-request", resolution.requestedRuntime,
55
84
  "--runtime-resolution-json", JSON.stringify(resolution),
56
- ...withoutLeadRuntimeFlags(args),
85
+ ...withoutRuntimeSelection(args),
57
86
  ];
58
87
  }
59
88
 
60
89
  export function buildRunPlan({ args, paths, resolution, runManifestPath = "" }) {
61
90
  void paths;
62
91
  const projectRoot = projectRootFromArgs(args);
63
- if (resolution.resolvedRuntime === "claude-code") {
64
- return {
65
- ok: false,
66
- reason: "Bare okstra run cannot invoke host-native Claude workers. Use the okstra-run skill in Claude Code or pass --lead-runtime external.",
67
- commands: [],
68
- };
69
- }
70
-
71
92
  if (resolution.resolvedRuntime === "external") {
72
93
  return {
73
94
  ok: true,
95
+ kind: "team",
74
96
  reason: resolution.reason,
75
97
  commands: [
76
98
  { name: "render-bundle", args: renderArgs(args, resolution), capture: true },
@@ -80,19 +102,21 @@ export function buildRunPlan({ args, paths, resolution, runManifestPath = "" })
80
102
  };
81
103
  }
82
104
 
83
- if (resolution.resolvedRuntime === "codex") {
84
- return {
85
- ok: false,
86
- reason: "Bare okstra run cannot invoke host-native Codex workers. Use the okstra-run skill in Codex or pass --lead-runtime external for an all-CLI run.",
87
- commands: [],
88
- };
89
- }
90
-
91
- if (resolution.resolvedRuntime === "antigravity") {
105
+ if (LAUNCHABLE_RUNTIMES.has(resolution.resolvedRuntime)) {
92
106
  return {
93
- ok: false,
94
- reason: "Bare okstra run cannot invoke host-native Antigravity workers. Use the okstra-run skill in Antigravity or pass --lead-runtime external for an all-CLI run.",
95
- commands: [],
107
+ ok: true,
108
+ kind: "launch-lead",
109
+ reason: resolution.reason,
110
+ // Handed to okstra.sh, which owns input collection and the launch. Only
111
+ // the runtime is decided here; everything else is passed through so the
112
+ // launcher can prompt for whatever is still missing.
113
+ commands: [
114
+ {
115
+ name: "launcher",
116
+ args: ["--lead-runtime", resolution.resolvedRuntime, ...withoutRuntimeSelection(args)],
117
+ capture: false,
118
+ },
119
+ ],
96
120
  };
97
121
  }
98
122
 
@@ -114,6 +138,27 @@ async function runRenderCommand(command, paths) {
114
138
  return result;
115
139
  }
116
140
 
141
+ async function launchLead(command, paths) {
142
+ // okstra.sh already collects missing task inputs, prepares the bundle, asks
143
+ // about a sandbox waiver, and execs the lead. Reimplementing any of that here
144
+ // would give the two front doors separate answers to the same questions —
145
+ // most visibly, this one had no way to ask for a task id and simply forwarded
146
+ // the gap to python's argument parser.
147
+ const launcher = join(paths.bin, "okstra.sh");
148
+ if (!existsSync(launcher)) {
149
+ process.stderr.write(
150
+ `error: launcher not found at ${launcher} — run 'okstra install'\n`,
151
+ );
152
+ return 1;
153
+ }
154
+ const started = spawnSync(launcher, command.args, { stdio: "inherit" });
155
+ if (started.error) {
156
+ process.stderr.write(`error: could not start the launcher: ${started.error.message}\n`);
157
+ return 1;
158
+ }
159
+ return started.status ?? 1;
160
+ }
161
+
117
162
  async function runTeam(args) {
118
163
  const { run: runTeamCommand } = await import("./team.mjs");
119
164
  return await runTeamCommand(args);
@@ -147,6 +192,10 @@ export async function run(args) {
147
192
  const ensureCode = await ensureRuntime(requestedRuntime);
148
193
  if (ensureCode !== 0) return ensureCode;
149
194
 
195
+ if (firstPlan.kind === "launch-lead") {
196
+ return await launchLead(firstPlan.commands[0], paths);
197
+ }
198
+
150
199
  const render = firstPlan.commands[0];
151
200
  const prepared = await runRenderCommand(render, paths);
152
201
  if (prepared.code !== 0) return prepared.code ?? 1;