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 +17 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/interactive.sh +5 -5
- package/runtime/bin/lib/okstra/project-resolver.sh +3 -3
- package/runtime/bin/okstra.sh +64 -14
- package/runtime/prompts/launch.template.md +4 -0
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +27 -0
- package/runtime/python/okstra_ctl/cmux.py +64 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +1 -13
- package/runtime/python/okstra_ctl/dispatch_core.py +29 -11
- package/runtime/python/okstra_ctl/incremental_scope.py +37 -0
- package/runtime/python/okstra_ctl/models.py +87 -0
- package/runtime/python/okstra_ctl/run.py +86 -6
- package/runtime/python/okstra_ctl/wizard.py +171 -18
- package/runtime/skills/okstra-run/SKILL.md +1 -0
- package/src/commands/execute/run.mjs +79 -30
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
|
|
398
|
-
-
|
|
399
|
-
-
|
|
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
package/runtime/BUILD.json
CHANGED
|
@@ -59,14 +59,14 @@ resolve_task_root_for_shortcut() {
|
|
|
59
59
|
local task_id="$4"
|
|
60
60
|
|
|
61
61
|
local resolved=""
|
|
62
|
-
resolved="$(python3 - "$
|
|
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
|
-
# 동일하게 $
|
|
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="$
|
|
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="$
|
|
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="$
|
|
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 - "$
|
|
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 - "$
|
|
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 - "$
|
|
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
|
package/runtime/bin/okstra.sh
CHANGED
|
@@ -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="$
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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["
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2910
|
-
print(f"
|
|
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
|
|
2913
|
-
#
|
|
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
|
-
"
|
|
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(
|
|
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
|
|
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
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4813
|
+
"""이번 clarification 재실행이 좁혀질지 — 확인 단계에서 보여주는 줄.
|
|
4814
|
+
|
|
4815
|
+
사용자가 범위를 직접 골랐으면 그 선택을 찍는다. 고르지 않았으면(또는 좁힐
|
|
4816
|
+
수 없어 질문 자체가 안 뜬 경우) 예측을 찍는다. 예측의 절반(답변된 id 가
|
|
4817
|
+
stage 로 되짚어지는지)은 base SHA 없이 직전 리포트만으로 이미 정해져 있는데,
|
|
4818
|
+
지금까지는 `okstra recap assemble` 을 따로 돌려야만 보였고 run 이 시작된 뒤
|
|
4819
|
+
full 로 밝혀지면 두 시간을 물린 뒤였다. 확인 단계는 그 전에 되돌릴 수 있는
|
|
4820
|
+
마지막 지점이다.
|
|
4665
4821
|
"""
|
|
4666
|
-
|
|
4667
|
-
|
|
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
|
-
|
|
4671
|
-
state.
|
|
4672
|
-
|
|
4673
|
-
if
|
|
4674
|
-
return
|
|
4675
|
-
|
|
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>
|
|
10
|
-
--task-id <tid> --task-type <type>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
55
|
+
export function withoutRuntimeSelection(args) {
|
|
56
|
+
const rest = positionalRuntime(args) ? args.slice(1) : args;
|
|
28
57
|
const out = [];
|
|
29
|
-
for (let i = 0; i <
|
|
30
|
-
const arg =
|
|
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
|
-
...
|
|
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
|
|
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:
|
|
94
|
-
|
|
95
|
-
|
|
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;
|