okstra 0.160.0 → 0.162.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +1 -0
- package/docs/cli.md +17 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra.sh +46 -13
- package/runtime/prompts/launch.template.md +4 -0
- package/runtime/prompts/lead/adapters/cmux.md +67 -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 +595 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +1 -13
- package/runtime/python/okstra_ctl/dispatch_core.py +101 -16
- package/runtime/python/okstra_ctl/dispatch_state.py +16 -0
- package/runtime/python/okstra_ctl/incremental_scope.py +37 -0
- package/runtime/python/okstra_ctl/lead_runtime.py +30 -2
- package/runtime/python/okstra_ctl/models.py +87 -0
- package/runtime/python/okstra_ctl/render.py +7 -2
- package/runtime/python/okstra_ctl/run.py +99 -7
- package/runtime/python/okstra_ctl/team.py +50 -11
- package/runtime/python/okstra_ctl/wizard.py +171 -18
- package/runtime/skills/okstra-run/SKILL.md +1 -0
- package/runtime/validators/validate-workflow.sh +6 -0
- package/runtime/validators/validate_session_conformance.py +37 -3
- package/src/commands/execute/run.mjs +117 -30
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
"""Neutral okstra team CLI for
|
|
1
|
+
"""Neutral okstra team CLI for pane-backed worker dispatch.
|
|
2
|
+
|
|
3
|
+
Outside cmux this is the external lead's door onto tmux panes. Under cmux it
|
|
4
|
+
is every lead's door onto cmux surfaces, because okstra owns the panes there
|
|
5
|
+
rather than the host. Which backend a run uses is read from its run manifest.
|
|
6
|
+
"""
|
|
2
7
|
from __future__ import annotations
|
|
3
8
|
|
|
4
9
|
import argparse
|
|
@@ -7,8 +12,10 @@ import sys
|
|
|
7
12
|
from pathlib import Path
|
|
8
13
|
from typing import Any, Mapping, Sequence
|
|
9
14
|
|
|
15
|
+
from . import cmux
|
|
10
16
|
from . import tmux
|
|
11
17
|
from .dispatch_core import (
|
|
18
|
+
BACKEND_CMUX_PANE,
|
|
12
19
|
BACKEND_TMUX_PANE,
|
|
13
20
|
DispatchError,
|
|
14
21
|
DispatchPlan,
|
|
@@ -51,7 +58,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
51
58
|
|
|
52
59
|
|
|
53
60
|
def _add_dispatch_parser(sub) -> None:
|
|
54
|
-
parser = sub.add_parser("dispatch", help="dispatch
|
|
61
|
+
parser = sub.add_parser("dispatch", help="dispatch pane-backed workers")
|
|
55
62
|
_add_run_args(parser)
|
|
56
63
|
parser.add_argument("--workers", default="")
|
|
57
64
|
parser.add_argument("--jobs-file", default="")
|
|
@@ -61,7 +68,7 @@ def _add_dispatch_parser(sub) -> None:
|
|
|
61
68
|
|
|
62
69
|
|
|
63
70
|
def _add_await_parser(sub) -> None:
|
|
64
|
-
parser = sub.add_parser("await", help="wait for
|
|
71
|
+
parser = sub.add_parser("await", help="wait for pane-backed workers")
|
|
65
72
|
_add_run_args(parser)
|
|
66
73
|
parser.add_argument("--poll-interval-seconds", type=int, default=5)
|
|
67
74
|
parser.add_argument("--timeout-seconds", type=int, default=None)
|
|
@@ -70,7 +77,7 @@ def _add_await_parser(sub) -> None:
|
|
|
70
77
|
|
|
71
78
|
|
|
72
79
|
def _add_teardown_parser(sub) -> None:
|
|
73
|
-
parser = sub.add_parser("teardown", help="
|
|
80
|
+
parser = sub.add_parser("teardown", help="reclaim this run's worker panes")
|
|
74
81
|
_add_run_args(parser)
|
|
75
82
|
parser.add_argument("--dry-run", action="store_true")
|
|
76
83
|
parser.add_argument("--json", action="store_true")
|
|
@@ -94,8 +101,8 @@ def _dispatch(args) -> int:
|
|
|
94
101
|
okstra_bin=Path(args.okstra_bin),
|
|
95
102
|
requested_workers=requested,
|
|
96
103
|
idle_timeout_seconds=args.idle_timeout_seconds,
|
|
97
|
-
required_lead_runtime="external",
|
|
98
|
-
default_backend=
|
|
104
|
+
required_lead_runtime=None if _is_cmux_run(manifest) else "external",
|
|
105
|
+
default_backend=_manifest_backend(manifest),
|
|
99
106
|
supported_worker_wrappers=_SUPPORTED_WRAPPERS,
|
|
100
107
|
unsupported_worker_label="external lead",
|
|
101
108
|
dispatch_kind=args.dispatch_kind,
|
|
@@ -133,13 +140,13 @@ def _teardown(args) -> int:
|
|
|
133
140
|
team_state_path = _resolve_project_path(project_root, _require_string(manifest, "teamStatePath"))
|
|
134
141
|
team_state = _load_json(team_state_path, "team-state")
|
|
135
142
|
run_dir = _resolve_project_path(project_root, _require_string(manifest, "runDirectoryPath"))
|
|
136
|
-
|
|
137
|
-
panes = _teardown_panes(team_state, run_dir, lead_pane)
|
|
143
|
+
panes = _reclaimable_panes(manifest, team_state, run_dir)
|
|
138
144
|
if args.dry_run:
|
|
139
145
|
_emit_teardown(args.json, panes)
|
|
140
146
|
return 0
|
|
147
|
+
reclaim = cmux.close_surface if _is_cmux_run(manifest) else tmux.kill_pane
|
|
141
148
|
for pane in panes:
|
|
142
|
-
|
|
149
|
+
reclaim(pane["paneId"])
|
|
143
150
|
_mark_teardown_errors(team_state_path)
|
|
144
151
|
_emit_teardown(args.json, panes)
|
|
145
152
|
return 0
|
|
@@ -158,7 +165,7 @@ def _plan_for_existing(
|
|
|
158
165
|
lead_events_path=_resolve_project_path(root, _require_string(manifest, "leadEventsPath")),
|
|
159
166
|
manifest=manifest,
|
|
160
167
|
jobs=(),
|
|
161
|
-
default_backend=
|
|
168
|
+
default_backend=_manifest_backend(manifest),
|
|
162
169
|
)
|
|
163
170
|
|
|
164
171
|
|
|
@@ -174,12 +181,25 @@ def _await_payload(plan: DispatchPlan, completed: bool) -> dict[str, Any]:
|
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
|
|
177
|
-
def
|
|
184
|
+
def _reclaimable_panes(
|
|
185
|
+
manifest: Mapping[str, Any], team_state: Mapping[str, Any], run_dir: Path
|
|
186
|
+
) -> list[dict[str, str]]:
|
|
187
|
+
"""Everything this run owns and may close.
|
|
188
|
+
|
|
189
|
+
Under cmux the recorded ids are the whole list. There is no per-pane tag API
|
|
190
|
+
to sweep with, and scanning by title would be worse than nothing: cmux labels
|
|
191
|
+
its own agent surfaces with the same glyph okstra's tmux cleanup treats as a
|
|
192
|
+
teammate marker, so a sweep could close the lead. Only surfaces okstra
|
|
193
|
+
created are recorded, so only those can be closed.
|
|
194
|
+
"""
|
|
178
195
|
seen: set[str] = set()
|
|
179
196
|
panes: list[dict[str, str]] = []
|
|
180
197
|
for record in team_state.get("workerDispatches", []):
|
|
181
198
|
if isinstance(record, dict):
|
|
182
199
|
_append_pane(panes, seen, str(record.get("paneId", "")), "worker")
|
|
200
|
+
if _is_cmux_run(manifest):
|
|
201
|
+
return panes
|
|
202
|
+
lead_pane = tmux.resolve_caller_pane()
|
|
183
203
|
for pane in tmux.list_run_panes(run_dir, lead_pane=lead_pane):
|
|
184
204
|
_append_pane(panes, seen, pane.pane_id, pane.kind)
|
|
185
205
|
return panes
|
|
@@ -208,10 +228,29 @@ def _emit_teardown(as_json: bool, panes: list[dict[str, str]]) -> None:
|
|
|
208
228
|
print(f"{pane['paneId']}\t{pane['kind']}")
|
|
209
229
|
|
|
210
230
|
|
|
231
|
+
def _manifest_backend(manifest: Mapping[str, Any]) -> str:
|
|
232
|
+
"""The backend prepare recorded for this run.
|
|
233
|
+
|
|
234
|
+
Deliberately not a flag on this command: the manifest already answers it,
|
|
235
|
+
and a flag would be a second answer free to disagree. A manifest written
|
|
236
|
+
before the field existed reads as tmux, which is what those runs used.
|
|
237
|
+
"""
|
|
238
|
+
return str(manifest.get("terminalBackend") or "") or BACKEND_TMUX_PANE
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _is_cmux_run(manifest: Mapping[str, Any]) -> bool:
|
|
242
|
+
return _manifest_backend(manifest) == BACKEND_CMUX_PANE
|
|
243
|
+
|
|
244
|
+
|
|
211
245
|
def _validate_external_manifest(manifest: Mapping[str, Any]) -> None:
|
|
212
246
|
runtime = manifest.get("leadRuntime")
|
|
213
247
|
if runtime == "external":
|
|
214
248
|
return
|
|
249
|
+
if _is_cmux_run(manifest):
|
|
250
|
+
# Under cmux okstra owns the panes for every lead, so this command is no
|
|
251
|
+
# longer the external lead's private door and the advice below no longer
|
|
252
|
+
# applies — there is nowhere else for a codex or Claude Code lead to go.
|
|
253
|
+
return
|
|
215
254
|
if runtime == "codex":
|
|
216
255
|
raise DispatchError("use okstra codex-dispatch for leadRuntime=codex")
|
|
217
256
|
if runtime == "claude-code":
|
|
@@ -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
|
|
|
@@ -39,6 +39,12 @@ SECONDARY_BRIEF_FILENAME="validation-brief-secondary.md"
|
|
|
39
39
|
export OKSTRA_SKIP_INSTALL_CHECK="${OKSTRA_SKIP_INSTALL_CHECK:-1}"
|
|
40
40
|
export OKSTRA_CTL_SKIP_RECONCILE="${OKSTRA_CTL_SKIP_RECONCILE:-1}"
|
|
41
41
|
export OKSTRA_CTL_SKIP_BACKFILL="${OKSTRA_CTL_SKIP_BACKFILL:-1}"
|
|
42
|
+
# Same reason as the three above: the synthetic run must render the same way on
|
|
43
|
+
# every machine. cmux outranks tmux when present, so a maintainer running this
|
|
44
|
+
# from inside cmux would otherwise get the cmux adapter and a lead-session
|
|
45
|
+
# requirement this fixture never simulates. The cmux path has its own coverage
|
|
46
|
+
# in tests/run/test_cmux*.py and tests/contract/test_validate_session_conformance.py.
|
|
47
|
+
export CMUX_WORKSPACE_ID=""
|
|
42
48
|
|
|
43
49
|
# shellcheck source=lib/common.sh
|
|
44
50
|
source "$SCRIPT_DIR/lib/common.sh"
|
|
@@ -112,9 +112,19 @@ _ENTRY_GUARD_READS = (
|
|
|
112
112
|
),
|
|
113
113
|
)
|
|
114
114
|
|
|
115
|
+
# 환경으로 선택되는 어댑터라 lead 의 런타임이 이 파일을 가르쳐 주지 않는다.
|
|
116
|
+
# 읽지 않은 lead 는 자기 런타임이 아는 방식 — cmux 경로에서는 okstra 가 소유한
|
|
117
|
+
# 디스패치를 host 네이티브로 가로채는 방식 — 으로 되돌아간다.
|
|
118
|
+
CMUX_ADAPTER_BASENAME = "cmux.md"
|
|
119
|
+
CMUX_ADAPTER_NAME = "cmux"
|
|
120
|
+
_CMUX_ADAPTER_CITE = "prompts/lead/adapters/cmux.md"
|
|
121
|
+
|
|
115
122
|
# Read 증거는 basename 으로 거른다 — 절대 경로는 레이어(repo / runtime / 설치본)
|
|
116
123
|
# 마다 다르지만 basename 은 동일하다. 목록이 갈리지 않도록 기대치에서 파생한다.
|
|
117
|
-
|
|
124
|
+
_TRACKED_READ_BASENAMES = (
|
|
125
|
+
*(row.basename for row in _ENTRY_GUARD_READS),
|
|
126
|
+
CMUX_ADAPTER_BASENAME,
|
|
127
|
+
)
|
|
118
128
|
|
|
119
129
|
|
|
120
130
|
@dataclass
|
|
@@ -208,7 +218,7 @@ def _scan_one_jsonl(
|
|
|
208
218
|
progress.append((ts, m.group("phase"), line))
|
|
209
219
|
elif block.get("type") == "tool_use" and block.get("name") == "Read":
|
|
210
220
|
base = Path(str((block.get("input") or {}).get("file_path") or "")).name
|
|
211
|
-
if base in
|
|
221
|
+
if base in _TRACKED_READ_BASENAMES:
|
|
212
222
|
reads.setdefault(base, []).append(ts)
|
|
213
223
|
return progress, reads, agent_name
|
|
214
224
|
|
|
@@ -334,7 +344,7 @@ def _sidecar_read_from_event(event) -> tuple[str, str] | None:
|
|
|
334
344
|
if not isinstance(basename, str) or not basename:
|
|
335
345
|
raw_path = details.get("path") or details.get("filePath")
|
|
336
346
|
basename = Path(str(raw_path or "")).name
|
|
337
|
-
if basename not in
|
|
347
|
+
if basename not in _TRACKED_READ_BASENAMES:
|
|
338
348
|
return None
|
|
339
349
|
return (basename, event.timestamp)
|
|
340
350
|
|
|
@@ -752,6 +762,29 @@ def _instruction_set_dir(run_dir: Path, suffix: str | None, project_root: Path)
|
|
|
752
762
|
return path if path.is_dir() else None
|
|
753
763
|
|
|
754
764
|
|
|
765
|
+
def _check_cmux_adapter_read(
|
|
766
|
+
evidence: _LeadEvidence, team_state: dict, errors: list[str]
|
|
767
|
+
) -> None:
|
|
768
|
+
"""검사 4 — cmux 어댑터 읽음. 모든 task-type 에 적용된다.
|
|
769
|
+
|
|
770
|
+
다른 어댑터는 lead 의 런타임이 고르지만 이것은 환경이 고른다. 그래서 읽지
|
|
771
|
+
않은 lead 에게는 이 경로가 존재한다는 사실 자체가 닿지 않고, 자기 런타임이
|
|
772
|
+
아는 host 네이티브 디스패치로 되돌아간다 — cmux 경로에서 okstra 가 소유한
|
|
773
|
+
바로 그 일이다."""
|
|
774
|
+
adapter = team_state.get("leadAdapter")
|
|
775
|
+
name = str(adapter.get("name", "")).strip() if isinstance(adapter, dict) else ""
|
|
776
|
+
if name != CMUX_ADAPTER_NAME:
|
|
777
|
+
return
|
|
778
|
+
if evidence.sidecar_reads.get(CMUX_ADAPTER_BASENAME):
|
|
779
|
+
return
|
|
780
|
+
errors.append(
|
|
781
|
+
f"cmux adapter: no `Read` of `{CMUX_ADAPTER_BASENAME}` found in the "
|
|
782
|
+
"selected adapter evidence source within this run's window — the cmux "
|
|
783
|
+
"adapter is selected by environment, not by lead runtime, so it MUST be "
|
|
784
|
+
f"read before dispatch ({_CMUX_ADAPTER_CITE})."
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
755
788
|
def _check_implementation_entry_guard(
|
|
756
789
|
evidence: _LeadEvidence, errors: list[str], instruction_set: Path | None
|
|
757
790
|
) -> None:
|
|
@@ -837,6 +870,7 @@ def validate_session_conformance(
|
|
|
837
870
|
result.errors.append(error)
|
|
838
871
|
return result
|
|
839
872
|
_check_progress_checkpoints(evidence, team_state, run_dir, suffix, result.errors)
|
|
873
|
+
_check_cmux_adapter_read(evidence, team_state, result.errors)
|
|
840
874
|
if task_type == "implementation":
|
|
841
875
|
_check_implementation_entry_guard(
|
|
842
876
|
evidence,
|