okstra 0.195.1 → 0.195.3
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 +1 -0
- package/docs/project-structure-overview.md +1 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/convergence.md +2 -0
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +2 -27
- package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +11 -3
- package/runtime/python/okstra_ctl/agent/prompt_cli/inputs.py +8 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +47 -3
- package/runtime/python/okstra_ctl/blocking_checks.py +12 -0
- package/runtime/python/okstra_ctl/convergence.py +77 -0
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +2 -2
- package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +221 -0
- package/runtime/python/okstra_ctl/convergence_engine.py +2 -2
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +4 -4
- package/runtime/python/okstra_ctl/convergence_store.py +8 -1
- package/runtime/python/okstra_ctl/dispatch_state.py +4 -2
- package/runtime/python/okstra_ctl/final_report_schema.py +59 -0
- package/runtime/python/okstra_ctl/report_corrections.py +1 -1
- package/runtime/python/okstra_ctl/report_html/render.py +10 -4
- package/runtime/python/okstra_ctl/report_narrative.py +4 -1
- package/runtime/python/okstra_ctl/report_projections.py +19 -1
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +20 -8
- package/runtime/python/okstra_ctl/wizard/engine.py +13 -4
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +14 -36
- package/runtime/python/okstra_ctl/wizard/roles.py +26 -82
- package/runtime/python/okstra_ctl/wizard/state.py +1 -5
- package/runtime/python/okstra_ctl/wizard/steps_roles.py +0 -1
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +22 -1
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +14 -1
- package/runtime/python/okstra_token_usage/collect.py +27 -6
- package/runtime/python/okstra_token_usage/pricing.py +39 -6
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/templates/reports/html/base.template.html +1 -1
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
"""호스트 선택기 한도 안에서 원래 선택지를 보존하는
|
|
2
|
-
from dataclasses import replace
|
|
1
|
+
"""호스트 선택기 한도 안에서 단일 선택의 원래 선택지를 보존하는 쪽 나누기.
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
체크박스(`multi`)는 여기 오지 않는다 — 네이티브 체크박스에 못 실으면
|
|
4
|
+
`numbered-multi` 로 전체 목록을 한 번에 보인다(`engine._native_picker_screen`).
|
|
5
|
+
쪽 사본이 추천 표시를 단 채 단일 선택으로 바뀌면 `Prompt` 의 추천 불변식에
|
|
6
|
+
걸리므로, 한 줄씩 토글하던 체크박스 쪽 나누기는 2026-09-09 에 뺐다.
|
|
7
|
+
"""
|
|
8
|
+
from dataclasses import replace
|
|
5
9
|
|
|
6
|
-
from .ids import
|
|
10
|
+
from .ids import PICK_TYPE_CUSTOM
|
|
7
11
|
from .state import Option, Prompt, WizardError, WizardState
|
|
8
12
|
|
|
9
13
|
_PAGE_PREFIX = "__okstra_picker_page__:"
|
|
10
|
-
_DONE = "__okstra_picker_done__"
|
|
11
14
|
|
|
12
15
|
|
|
13
16
|
def present_picker(state: WizardState, prompt: Prompt, *, limit: int) -> Prompt:
|
|
14
17
|
if prompt.kind != "pick" or not prompt.options:
|
|
15
18
|
return prompt
|
|
16
|
-
selected = state.picker_selected.get(prompt.step, [])
|
|
17
19
|
labels = [option.label for option in prompt.options]
|
|
18
20
|
options = [replace(
|
|
19
21
|
option,
|
|
20
|
-
label=("
|
|
21
|
-
+ (f" [{option.value}]" if labels.count(option.label) > 1 else ""),
|
|
22
|
+
label=option.label + (f" [{option.value}]" if labels.count(option.label) > 1 else ""),
|
|
22
23
|
) for option in prompt.options]
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
size = max(1, limit - reserve) if paged else len(options)
|
|
24
|
+
paged = len(options) > limit
|
|
25
|
+
size = max(1, limit - 1) if paged else len(options)
|
|
26
26
|
offset = state.picker_offsets.get(prompt.step, 0)
|
|
27
27
|
offset = offset if 0 <= offset < len(options) else 0
|
|
28
28
|
shown = options[offset:offset + size]
|
|
@@ -34,15 +34,12 @@ def present_picker(state: WizardState, prompt: Prompt, *, limit: int) -> Prompt:
|
|
|
34
34
|
"다음 선택지" if next_offset else "처음 선택지로",
|
|
35
35
|
))
|
|
36
36
|
label += f" ({offset + 1}–{min(offset + size, len(options))}/{len(options)})"
|
|
37
|
-
if prompt.multi:
|
|
38
|
-
shown.append(Option(_DONE, f"선택 완료 ({len(selected)}개)"))
|
|
39
|
-
label += " · 항목을 선택하면 선택/해제됩니다. 완료를 누르면 제출합니다."
|
|
40
37
|
if len(shown) == 1:
|
|
41
38
|
shown.append(Option(f"{_PAGE_PREFIX}0", "다시 보기"))
|
|
42
39
|
shown = [o for o in shown if o.value != PICK_TYPE_CUSTOM] + [
|
|
43
40
|
o for o in shown if o.value == PICK_TYPE_CUSTOM
|
|
44
41
|
]
|
|
45
|
-
return replace(prompt, label=label, options=shown
|
|
42
|
+
return replace(prompt, label=label, options=shown)
|
|
46
43
|
|
|
47
44
|
|
|
48
45
|
def accept_picker_answer(state: WizardState, prompt: Prompt, value: str) -> str | None:
|
|
@@ -52,24 +49,5 @@ def accept_picker_answer(state: WizardState, prompt: Prompt, value: str) -> str
|
|
|
52
49
|
raise WizardError("invalid picker page; select a returned navigation option")
|
|
53
50
|
state.picker_offsets[prompt.step] = int(raw_offset)
|
|
54
51
|
return None
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return value
|
|
58
|
-
if value != _DONE and value not in {o.value for o in prompt.options}:
|
|
59
|
-
raise WizardError("invalid picker choice; select an option from the current screen")
|
|
60
|
-
if value in {_DONE, PICK_TYPE_CUSTOM}:
|
|
61
|
-
selected = state.picker_selected.pop(prompt.step, [])
|
|
62
|
-
state.picker_offsets.pop(prompt.step, None)
|
|
63
|
-
return (
|
|
64
|
-
",".join(o.value for o in prompt.options if o.value in selected)
|
|
65
|
-
if value == _DONE else value
|
|
66
|
-
)
|
|
67
|
-
selected = state.picker_selected.setdefault(prompt.step, [])
|
|
68
|
-
if value in selected:
|
|
69
|
-
selected.remove(value)
|
|
70
|
-
elif value in {ALL_STAGES, WHOLE_TASK_STAGE}:
|
|
71
|
-
selected[:] = [value]
|
|
72
|
-
else:
|
|
73
|
-
selected[:] = [v for v in selected if v not in {ALL_STAGES, WHOLE_TASK_STAGE}]
|
|
74
|
-
selected.append(value)
|
|
75
|
-
return None
|
|
52
|
+
state.picker_offsets.pop(prompt.step, None)
|
|
53
|
+
return value
|
|
@@ -7,11 +7,13 @@ verifier: `max > 1`)은 체크박스 한 장이고, 고른 모델 수가 곧 인
|
|
|
7
7
|
(`min = 0`, 예: critic)은 같은 화면에 "추가 안 함" 줄이 있다(종전 `role-add:`).
|
|
8
8
|
고정 단일 역할(`min = max = 1`, 예: report-writer·implementer)은 단일 선택 한 장이다.
|
|
9
9
|
|
|
10
|
-
체크박스
|
|
11
|
-
`modelDefaults
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
체크박스 화면(`role-models:<role>`)은 실행 가능한 전체 후보를 한 번에 싣는다.
|
|
11
|
+
기본 후보(프로젝트 `modelDefaults`, 없으면 카탈로그 기본값)가 앞이고 권장
|
|
12
|
+
수만큼의 앞줄이 추천이다. 호스트 네이티브 선택기의 옵션 한도(claude-code 4,
|
|
13
|
+
codex 3, grok 15)를 넘으면 `CapabilityInteractionPort.plan` 이 `numbered-multi`
|
|
14
|
+
로 내려 번호 목록이 된다 — 기본 후보만 실은 짧은 화면과 "직접 선택" 이 여는
|
|
15
|
+
두 번째 화면으로 나누던 설계는 2026-09-09 사용자 요청으로 뺐다(후보 12개 중
|
|
16
|
+
3개만 보이고, 두 번째 화면은 쪽 나누기가 추천 불변식을 깨 열리지도 않았다).
|
|
15
17
|
"""
|
|
16
18
|
from __future__ import annotations
|
|
17
19
|
|
|
@@ -37,7 +39,6 @@ from okstra_ctl.run import (
|
|
|
37
39
|
_model_default_scopes,
|
|
38
40
|
)
|
|
39
41
|
|
|
40
|
-
from .ids import PICK_TYPE_CUSTOM
|
|
41
42
|
from .state import (
|
|
42
43
|
Option,
|
|
43
44
|
Prompt,
|
|
@@ -54,7 +55,6 @@ from .prompts import _opt, _p
|
|
|
54
55
|
ROLE_SKIP_TOKEN = "0"
|
|
55
56
|
|
|
56
57
|
_MODELS_PREFIX = "role-models:"
|
|
57
|
-
_CUSTOM_PREFIX = "role-models-custom:"
|
|
58
58
|
_SINGLE_PREFIX = "role-model:"
|
|
59
59
|
|
|
60
60
|
|
|
@@ -62,17 +62,13 @@ def _role_models_prompt_id(role: str) -> str:
|
|
|
62
62
|
return f"{_MODELS_PREFIX}{role}"
|
|
63
63
|
|
|
64
64
|
|
|
65
|
-
def _role_models_custom_prompt_id(role: str) -> str:
|
|
66
|
-
return f"{_CUSTOM_PREFIX}{role}"
|
|
67
|
-
|
|
68
|
-
|
|
69
65
|
def _role_model_prompt_id(role: str, ordinal: int) -> str:
|
|
70
66
|
return f"{_SINGLE_PREFIX}{role}:{ordinal}"
|
|
71
67
|
|
|
72
68
|
|
|
73
69
|
def _step_role(step_id: str) -> str:
|
|
74
70
|
"""역할 선택 step id 가 가리키는 역할. 역할 선택 step 이 아니면 빈 문자열."""
|
|
75
|
-
for prefix in (
|
|
71
|
+
for prefix in (_MODELS_PREFIX, _SINGLE_PREFIX):
|
|
76
72
|
if step_id.startswith(prefix):
|
|
77
73
|
return step_id[len(prefix):].split(":", 1)[0]
|
|
78
74
|
return ""
|
|
@@ -131,7 +127,7 @@ def _count_range_text(requirement: RoleRequirement, t: dict) -> str:
|
|
|
131
127
|
def _role_models_texts(
|
|
132
128
|
state: WizardState, prompt_key: str, requirement: RoleRequirement,
|
|
133
129
|
) -> dict:
|
|
134
|
-
"""`role_models`
|
|
130
|
+
"""`role_models` 문구. 허용 범위 문장은 `range` 키다."""
|
|
135
131
|
probe = _p(state.workspace_root, prompt_key, role=requirement.role, range="")
|
|
136
132
|
range_text = _count_range_text(requirement, probe)
|
|
137
133
|
texts = _p(state.workspace_root, prompt_key, role=requirement.role, range=range_text)
|
|
@@ -199,22 +195,6 @@ def _available_role_models(
|
|
|
199
195
|
return defaults, everything
|
|
200
196
|
|
|
201
197
|
|
|
202
|
-
def _shortlist_budget(state: WizardState, requirement: RoleRequirement) -> int:
|
|
203
|
-
"""`role-models:` 화면에 실을 모델 줄 수.
|
|
204
|
-
|
|
205
|
-
호스트에 네이티브 선택기가 있으면 그 한도에서 "직접 선택" 한 줄과, 선택
|
|
206
|
-
역할이면 "추가 안 함" 한 줄을 뺀 수다. 한도를 넘기는 순간
|
|
207
|
-
`CapabilityInteractionPort.plan` 이 번호 목록으로 내려 체크박스가 아니게
|
|
208
|
-
된다. 선택기 자체가 없는 호스트는 어차피 번호 목록이므로 자르지 않는다.
|
|
209
|
-
"""
|
|
210
|
-
functions = set(state.available_functions)
|
|
211
|
-
if "native_single_select" not in functions and "native_multi_select" not in functions:
|
|
212
|
-
return 10**6
|
|
213
|
-
port = default_host_registry().resolve(state.host_runtime).interaction()
|
|
214
|
-
reserved = 1 + (1 if requirement.min_count == 0 else 0)
|
|
215
|
-
return max(1, port.native_option_limit - reserved)
|
|
216
|
-
|
|
217
|
-
|
|
218
198
|
def _skip_option(requirement: RoleRequirement, t: dict, *, recommended: bool) -> Option:
|
|
219
199
|
# 역할을 빼면 그 역할이 맡던 판정이 사라진다. 그 결과를 아는 역할만 경고를
|
|
220
200
|
# 단다 — 예: critic 이 없으면 분석자 동수를 가를 주체가 없다.
|
|
@@ -233,40 +213,34 @@ def _role_models_prompt(
|
|
|
233
213
|
requirement: RoleRequirement,
|
|
234
214
|
context: AssignmentContext,
|
|
235
215
|
scopes: ModelDefaultScopes,
|
|
236
|
-
*,
|
|
237
|
-
full: bool,
|
|
238
216
|
) -> Prompt:
|
|
239
|
-
"""역할 하나의 모델
|
|
217
|
+
"""역할 하나의 모델 화면 — 실행 가능한 전체 후보, 기본 후보가 앞이다.
|
|
240
218
|
|
|
241
219
|
추천은 권장 수만큼의 앞줄이다 — 프로젝트 `modelDefaults`(없으면 카탈로그
|
|
242
220
|
기본값) 순서가 그 근거다. 권장이 0인 선택 역할은 "추가 안 함" 이 추천이다.
|
|
243
221
|
"""
|
|
244
222
|
role = requirement.role
|
|
245
223
|
pool = context.pool
|
|
246
|
-
|
|
224
|
+
_defaults, everything = _available_role_models(
|
|
247
225
|
state, profile, requirement, context, scopes,
|
|
248
226
|
)
|
|
249
227
|
if not everything:
|
|
250
228
|
_validate_role_selection_feasibility(state, profile, context, scopes)
|
|
251
229
|
raise WizardError(f"role {role!r} has no executable model candidates")
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
shown = everything
|
|
261
|
-
prompt_key = "role_models_custom" if custom else "role_models"
|
|
262
|
-
t = _role_models_texts(state, prompt_key, requirement)
|
|
263
|
-
label = t["label"]
|
|
230
|
+
t = _role_models_texts(state, "role_models", requirement)
|
|
231
|
+
multi = requirement.max_count > 1
|
|
232
|
+
# "고른 만큼 인스턴스를 띄웁니다" 는 체크박스 문구다 — 최대 1개인 역할(critic)은
|
|
233
|
+
# 단일 선택이라 인스턴스가 1개로 고정이고, 문구가 그 사실을 말해야 한다
|
|
234
|
+
# (실측 2026-09-09, critic 화면이 체크박스 문구를 달고 나왔다).
|
|
235
|
+
label = t["label"] if multi else t["labels"]["single"].format(
|
|
236
|
+
role=requirement.role, range=t["range"],
|
|
237
|
+
)
|
|
264
238
|
optional = requirement.min_count == 0
|
|
265
239
|
skip_first = optional and requirement.recommended_count == 0
|
|
266
240
|
options: list[Option] = []
|
|
267
241
|
if skip_first:
|
|
268
242
|
options.append(_skip_option(requirement, t, recommended=True))
|
|
269
|
-
for index, model_ref in enumerate(
|
|
243
|
+
for index, model_ref in enumerate(everything):
|
|
270
244
|
model = pool.resolve(model_ref)
|
|
271
245
|
options.append(_opt(
|
|
272
246
|
model_ref,
|
|
@@ -275,18 +249,10 @@ def _role_models_prompt(
|
|
|
275
249
|
))
|
|
276
250
|
if optional and not skip_first:
|
|
277
251
|
options.append(_skip_option(requirement, t, recommended=False))
|
|
278
|
-
if not full:
|
|
279
|
-
options.append(_opt(
|
|
280
|
-
PICK_TYPE_CUSTOM,
|
|
281
|
-
t["options"][PICK_TYPE_CUSTOM].format(total=len(everything)),
|
|
282
|
-
))
|
|
283
252
|
return Prompt(
|
|
284
|
-
step=(
|
|
285
|
-
_role_models_custom_prompt_id(role) if custom
|
|
286
|
-
else _role_models_prompt_id(role)
|
|
287
|
-
),
|
|
253
|
+
step=_role_models_prompt_id(role),
|
|
288
254
|
kind="pick",
|
|
289
|
-
multi=
|
|
255
|
+
multi=multi,
|
|
290
256
|
label=label,
|
|
291
257
|
options=options,
|
|
292
258
|
echo_template=t["echo_template"],
|
|
@@ -362,9 +328,6 @@ def _drop_role_selection(state: WizardState, roles: set[str]) -> None:
|
|
|
362
328
|
for role in roles:
|
|
363
329
|
state.role_models.pop(role, None)
|
|
364
330
|
state.role_counts.pop(role, None)
|
|
365
|
-
state.role_models_custom = [
|
|
366
|
-
role for role in state.role_models_custom if role not in roles
|
|
367
|
-
]
|
|
368
331
|
state.role_selection_order = [
|
|
369
332
|
step_id for step_id in state.role_selection_order
|
|
370
333
|
if _step_role(step_id) not in roles
|
|
@@ -571,14 +534,8 @@ def next_role_prompt(state: WizardState) -> Prompt | None:
|
|
|
571
534
|
if not selected:
|
|
572
535
|
return _model_prompt(state, profile, requirement, context, scopes)
|
|
573
536
|
continue
|
|
574
|
-
if role in state.role_models_custom:
|
|
575
|
-
return _role_models_prompt(
|
|
576
|
-
state, profile, requirement, context, scopes, full=True,
|
|
577
|
-
)
|
|
578
537
|
if role not in state.role_counts:
|
|
579
|
-
return _role_models_prompt(
|
|
580
|
-
state, profile, requirement, context, scopes, full=False,
|
|
581
|
-
)
|
|
538
|
+
return _role_models_prompt(state, profile, requirement, context, scopes)
|
|
582
539
|
count = state.role_counts[role]
|
|
583
540
|
if count != len(selected) or not (
|
|
584
541
|
(0 if requirement.min_count == 0 else requirement.min_count)
|
|
@@ -589,9 +546,7 @@ def next_role_prompt(state: WizardState) -> Prompt | None:
|
|
|
589
546
|
state,
|
|
590
547
|
{later.role for later in requirements[requirements.index(requirement):]},
|
|
591
548
|
)
|
|
592
|
-
return _role_models_prompt(
|
|
593
|
-
state, profile, requirement, context, scopes, full=False,
|
|
594
|
-
)
|
|
549
|
+
return _role_models_prompt(state, profile, requirement, context, scopes)
|
|
595
550
|
_validate_role_selection_feasibility(state, profile, context, scopes)
|
|
596
551
|
return None
|
|
597
552
|
|
|
@@ -612,20 +567,12 @@ def _submit_role_models(
|
|
|
612
567
|
) -> str:
|
|
613
568
|
"""체크박스(또는 선택 역할 단일 픽)의 답을 그 역할의 수와 모델로 확정한다."""
|
|
614
569
|
role = requirement.role
|
|
615
|
-
|
|
616
|
-
"role_models_custom" if prompt.step.startswith(_CUSTOM_PREFIX)
|
|
617
|
-
else "role_models"
|
|
618
|
-
)
|
|
619
|
-
t = _role_models_texts(state, prompt_key, requirement)
|
|
570
|
+
t = _role_models_texts(state, "role_models", requirement)
|
|
620
571
|
range_text = t["range"]
|
|
621
572
|
# 같은 값이 두 번 오면(번호 목록에서 `1,1`) 한 번으로 센다 — 거절할 일이 아니다.
|
|
622
573
|
picked = list(dict.fromkeys(
|
|
623
574
|
item.strip() for item in (value or "").split(",") if item.strip()
|
|
624
575
|
))
|
|
625
|
-
if PICK_TYPE_CUSTOM in picked:
|
|
626
|
-
if role not in state.role_models_custom:
|
|
627
|
-
state.role_models_custom.append(role)
|
|
628
|
-
return t["echo_variants"]["custom"]
|
|
629
576
|
allowed = {option.value for option in prompt.options}
|
|
630
577
|
unknown = [item for item in picked if item not in allowed]
|
|
631
578
|
if unknown:
|
|
@@ -638,7 +585,6 @@ def _submit_role_models(
|
|
|
638
585
|
previous = (state.role_counts.get(role), state.role_models.get(role))
|
|
639
586
|
state.role_counts[role] = 0
|
|
640
587
|
state.role_models.pop(role, None)
|
|
641
|
-
state.role_models_custom = [r for r in state.role_models_custom if r != role]
|
|
642
588
|
return t["echo_variants"]["skipped"]
|
|
643
589
|
if not requirement.min_count <= len(models) <= requirement.max_count:
|
|
644
590
|
raise WizardError(t["errors"]["count_out_of_range"].format(
|
|
@@ -648,7 +594,6 @@ def _submit_role_models(
|
|
|
648
594
|
previous_models = state.role_models.get(role)
|
|
649
595
|
state.role_counts[role] = len(models)
|
|
650
596
|
state.role_models[role] = list(models)
|
|
651
|
-
state.role_models_custom = [r for r in state.role_models_custom if r != role]
|
|
652
597
|
try:
|
|
653
598
|
_validate_submitted_role_model(state, profile)
|
|
654
599
|
except WizardError:
|
|
@@ -691,7 +636,7 @@ def _submit_role_prompt(state: WizardState, prompt: Prompt, value: str) -> str:
|
|
|
691
636
|
profile = _load_role_profile_for_state(state)
|
|
692
637
|
role = _step_role(prompt.step)
|
|
693
638
|
requirement = _requirement_for(profile, role)
|
|
694
|
-
if prompt.step.startswith(
|
|
639
|
+
if prompt.step.startswith(_MODELS_PREFIX):
|
|
695
640
|
if _fixed_single(requirement):
|
|
696
641
|
raise WizardError(f"role {role!r} takes exactly one model")
|
|
697
642
|
return _submit_role_models(state, profile, requirement, prompt, value)
|
|
@@ -731,7 +676,6 @@ def _reset_role_selection_from(state: WizardState, target_step: str) -> None:
|
|
|
731
676
|
def _clear_role_selection(state: WizardState) -> None:
|
|
732
677
|
state.role_counts.clear()
|
|
733
678
|
state.role_models.clear()
|
|
734
|
-
state.role_models_custom.clear()
|
|
735
679
|
state.role_selection_order.clear()
|
|
736
680
|
state.answered = [
|
|
737
681
|
step_id
|
|
@@ -34,8 +34,6 @@ class WizardState:
|
|
|
34
34
|
role_counts: dict[str, int] = field(default_factory=dict)
|
|
35
35
|
role_models: dict[str, list[str]] = field(default_factory=dict)
|
|
36
36
|
role_selection_order: list[str] = field(default_factory=list)
|
|
37
|
-
# 체크박스 화면에서 "직접 선택" 을 골라 전체 후보 화면을 기다리는 역할들.
|
|
38
|
-
role_models_custom: list[str] = field(default_factory=list)
|
|
39
37
|
|
|
40
38
|
# bootstrap
|
|
41
39
|
workspace_root: str = ""
|
|
@@ -45,7 +43,6 @@ class WizardState:
|
|
|
45
43
|
host_entry_mode: str = "current-session"
|
|
46
44
|
available_functions: list[str] = field(default_factory=list)
|
|
47
45
|
picker_offsets: dict[str, int] = field(default_factory=dict)
|
|
48
|
-
picker_selected: dict[str, list[str]] = field(default_factory=dict)
|
|
49
46
|
|
|
50
47
|
# task identity
|
|
51
48
|
is_new_task: Optional[bool] = None
|
|
@@ -583,7 +580,7 @@ def _validate_v2_state_fields(data: dict[str, Any]) -> None:
|
|
|
583
580
|
|
|
584
581
|
|
|
585
582
|
def _is_role_selection_step(step_id: str) -> bool:
|
|
586
|
-
return step_id.startswith(("role-model:", "role-models:"
|
|
583
|
+
return step_id.startswith(("role-model:", "role-models:"))
|
|
587
584
|
|
|
588
585
|
|
|
589
586
|
def _discard_implicit_leader_selection(state: WizardState) -> None:
|
|
@@ -672,7 +669,6 @@ _FIELD_DEFAULTS: dict[str, Any] = {
|
|
|
672
669
|
"executor": "", "critic": "", "critic_pending_text": False,
|
|
673
670
|
"execution_identity_version": 1,
|
|
674
671
|
"role_counts": {}, "role_models": {}, "role_selection_order": [],
|
|
675
|
-
"role_models_custom": [],
|
|
676
672
|
"reuse_previous": None,
|
|
677
673
|
"use_defaults": None, "workers_override": "",
|
|
678
674
|
"workers_custom_pending": False,
|
|
@@ -293,7 +293,6 @@ def _submit_reuse_previous(state: WizardState, value: str) -> Optional[str]:
|
|
|
293
293
|
conversion_payload.setdefault("kimiModel", state.kimi_model)
|
|
294
294
|
state.role_counts.clear()
|
|
295
295
|
state.role_models.clear()
|
|
296
|
-
state.role_models_custom.clear()
|
|
297
296
|
state.role_selection_order.clear()
|
|
298
297
|
_convert_v1_provider_selections(state, conversion_payload)
|
|
299
298
|
directive = inputs.get("directive")
|
|
@@ -10,11 +10,15 @@ from pathlib import Path
|
|
|
10
10
|
from typing import Any, Iterable, Mapping, Sequence
|
|
11
11
|
|
|
12
12
|
from .convergence_reverify_prompt import RENDERED_BY_LINE
|
|
13
|
+
from .convergence_critic_verify_prompt import (
|
|
14
|
+
RENDERED_BY_LINE as CRITIC_VERIFY_RENDERED_BY_LINE,
|
|
15
|
+
)
|
|
13
16
|
from .worker_prompt_body import analysis_worker_label
|
|
14
17
|
from .json_boundary import load_owned_object
|
|
15
18
|
from .worker_prompt_policy import (
|
|
16
19
|
ERRORS_PATH_HEADERS,
|
|
17
20
|
IMPLEMENTATION_HEADERS,
|
|
21
|
+
CRITIC_VERIFY_DISPATCH_KIND,
|
|
18
22
|
PromptPlan,
|
|
19
23
|
resolve_prompt_plan_for_manifest,
|
|
20
24
|
)
|
|
@@ -273,6 +277,7 @@ def validate_reverify_prompt(
|
|
|
273
277
|
task_type: str,
|
|
274
278
|
forbidden_actions: str,
|
|
275
279
|
expected_model: str | None = None,
|
|
280
|
+
dispatch_kind: str = "reverify-r1",
|
|
276
281
|
) -> list[str]:
|
|
277
282
|
"""Require the active phase boundary in a lightweight reverify prompt.
|
|
278
283
|
|
|
@@ -281,6 +286,11 @@ def validate_reverify_prompt(
|
|
|
281
286
|
a model the runtime does not serve does not fail here — it fails as a
|
|
282
287
|
provider 400 once the worker launches, where it reads as a worker fault.
|
|
283
288
|
Pass the dispatch's model so the mismatch is caught before launch.
|
|
289
|
+
|
|
290
|
+
``dispatch_kind`` selects the renderer whose signature the instruction must
|
|
291
|
+
carry: a numbered round is rendered by `okstra convergence reverify-prompt`,
|
|
292
|
+
the critic gap round (`critic-verify`) by `okstra convergence
|
|
293
|
+
critic-verify-prompt`. Either way a hand-written instruction is refused.
|
|
284
294
|
"""
|
|
285
295
|
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
286
296
|
errors: list[str] = _validate_model_header(normalized, expected_model)
|
|
@@ -314,7 +324,18 @@ def validate_reverify_prompt(
|
|
|
314
324
|
and boundary_position > first_heading.start()
|
|
315
325
|
):
|
|
316
326
|
errors.append("phase boundary block must precede reverify instructions")
|
|
317
|
-
|
|
327
|
+
instructions = normalized[_task_instructions_offset(normalized):]
|
|
328
|
+
if dispatch_kind == CRITIC_VERIFY_DISPATCH_KIND:
|
|
329
|
+
if CRITIC_VERIFY_RENDERED_BY_LINE not in instructions:
|
|
330
|
+
errors.append(
|
|
331
|
+
"critic-verify instruction is not the output of `okstra convergence "
|
|
332
|
+
"critic-verify-prompt` (missing the `**Rendered by:**` line) — render "
|
|
333
|
+
"it with `okstra convergence critic-verify-prompt --run-manifest "
|
|
334
|
+
"<run-manifest> --gaps <coverage-batch.json> --worker <worker-id>` and "
|
|
335
|
+
"pass that output verbatim as --instruction; hand-written gap "
|
|
336
|
+
"verification instructions are refused"
|
|
337
|
+
)
|
|
338
|
+
elif RENDERED_BY_LINE not in instructions:
|
|
318
339
|
# 손으로 쓴 지시문이 한 라운드를 버렸다(2026-09-09: `- Verdict:` 형식과 축약된
|
|
319
340
|
# 근거). 렌더러의 서명 줄이 없으면 그 지시문은 렌더러 출력이 아니다.
|
|
320
341
|
errors.append(
|
|
@@ -78,6 +78,19 @@ WORKER_PREAMBLE_FILENAME_BY_AUDIENCE = {
|
|
|
78
78
|
"report-writer": "report-writer-prompt-preamble.md",
|
|
79
79
|
}
|
|
80
80
|
WORKER_ERROR_CONTRACT_FILENAME = "worker-error-contract.md"
|
|
81
|
+
# coverage-critic gap 검증 라운드의 dispatch kind. 번호 라운드(`reverify-r<N>`)와
|
|
82
|
+
# 같은 검증 계약(audience `reverification-worker`, 렌더러 서명, 출력 계약)을
|
|
83
|
+
# 따르되 라운드 원장 밖이라 번호가 없다. 실측(2026-09-09 dev-10642): 이 kind 가
|
|
84
|
+
# 없어 gap 검증 프롬프트를 만들 수 없었고 gap 3건 전부 `gapsUnverified` 로 남았다.
|
|
85
|
+
CRITIC_VERIFY_DISPATCH_KIND = "critic-verify"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_verification_dispatch_kind(dispatch_kind: str) -> bool:
|
|
89
|
+
"""번호 reverify 라운드와 critic gap 검증 — 검증 프롬프트 계약을 받는 kind."""
|
|
90
|
+
return (
|
|
91
|
+
dispatch_kind.startswith("reverify-r")
|
|
92
|
+
or dispatch_kind == CRITIC_VERIFY_DISPATCH_KIND
|
|
93
|
+
)
|
|
81
94
|
|
|
82
95
|
|
|
83
96
|
@dataclass(frozen=True)
|
|
@@ -114,7 +127,7 @@ def resolve_prompt_plan(
|
|
|
114
127
|
if worker_id != "translator" or dispatch_kind != "translator":
|
|
115
128
|
raise ValueError("translator worker and dispatch kind must match")
|
|
116
129
|
return _plan("translator", duty_audience="translator")
|
|
117
|
-
if dispatch_kind
|
|
130
|
+
if is_verification_dispatch_kind(dispatch_kind):
|
|
118
131
|
return _plan("reverify")
|
|
119
132
|
# A critic pass keeps the full analysis contract — worker anchor headers, the
|
|
120
133
|
# audit sidecar, the packet boundary — but is exempt from the equality group.
|
|
@@ -35,7 +35,7 @@ from .grok import (
|
|
|
35
35
|
grok_session_window_total,
|
|
36
36
|
)
|
|
37
37
|
from .paths import claude_project_dir, find_session_jsonl, utc_now
|
|
38
|
-
from .pricing import antigravity_cost_usd, provider_cost_usd
|
|
38
|
+
from .pricing import antigravity_cost_usd, provider_billable_equivalent, provider_cost_usd
|
|
39
39
|
from okstra_ctl.dispatch_state import worker_dispatch_records, worker_session_ids
|
|
40
40
|
from okstra_ctl.models import provider_wrappers
|
|
41
41
|
from okstra_ctl.wrapper_status import (
|
|
@@ -669,21 +669,42 @@ def _cli_session_totals(
|
|
|
669
669
|
return totals
|
|
670
670
|
|
|
671
671
|
|
|
672
|
+
_THREE_RATE_PROVIDERS = frozenset({"codex", "grok", "kimi"})
|
|
673
|
+
|
|
674
|
+
|
|
672
675
|
def _cli_usage_block(provider: str, totals: dict, session_paths: list[Path]) -> dict:
|
|
676
|
+
"""CLI 공급자 세션 합계를 보고서가 읽는 usage 블록으로.
|
|
677
|
+
|
|
678
|
+
codex·grok·kimi 는 캐시 재읽기를 입력 토큰 안에 넣어 보고한다(codex
|
|
679
|
+
`cached_input_tokens ⊂ input_tokens`, grok `cachedReadTokens ⊂ inputTokens`).
|
|
680
|
+
보고서 표는 원시 토큰을 "캐시 재읽기를 뺀 값" 으로 정의하고 캐시 재읽기·과금
|
|
681
|
+
환산 토큰에 자기 칸을 두므로, claude-jsonl 블록과 같은 키(`cacheReadTokens`,
|
|
682
|
+
`billableEquivalentTokens`)로 같은 정의의 값을 싣는다 — 종전엔
|
|
683
|
+
`cachedInputTokens` 키에만 남아 표의 두 칸이 `--` 로 찍혔다(실측 2026-09-09,
|
|
684
|
+
dev-10627-2 planning 002: grok 890,880 / codex 3,864,448 토큰이 빠짐).
|
|
685
|
+
비용은 `cliEstimatedCostUsd` 그대로다 — 합계표가 CLI 비용을 별도 행으로 둔다.
|
|
686
|
+
"""
|
|
673
687
|
block = usage_block(totals, source=f"{provider}-cli")
|
|
674
|
-
|
|
688
|
+
cached_input = totals.get("cachedInputTokens", 0) or 0
|
|
689
|
+
if provider in _THREE_RATE_PROVIDERS and cached_input:
|
|
690
|
+
block["totalTokens"] = max(0, block["totalTokens"] - cached_input)
|
|
691
|
+
block["cacheReadTokens"] = cached_input
|
|
692
|
+
block["cliTotalTokens"] = block["totalTokens"]
|
|
675
693
|
block["cliSessionPaths"] = [str(path) for path in session_paths]
|
|
676
694
|
if totals.get("model"):
|
|
677
695
|
block["model"] = totals["model"]
|
|
678
696
|
block["cliModel"] = totals["model"]
|
|
679
|
-
if provider in
|
|
680
|
-
|
|
681
|
-
provider,
|
|
697
|
+
if provider in _THREE_RATE_PROVIDERS:
|
|
698
|
+
rates = (
|
|
682
699
|
totals.get("model"),
|
|
683
700
|
totals.get("inputTokens", 0) or 0,
|
|
684
|
-
|
|
701
|
+
cached_input,
|
|
685
702
|
totals.get("outputTokens", 0) or 0,
|
|
686
703
|
)
|
|
704
|
+
cost = provider_cost_usd(provider, *rates)
|
|
705
|
+
billable = provider_billable_equivalent(provider, *rates)
|
|
706
|
+
if billable is not None:
|
|
707
|
+
block["billableEquivalentTokens"] = billable
|
|
687
708
|
else:
|
|
688
709
|
cost = antigravity_cost_usd(
|
|
689
710
|
totals.get("model"),
|
|
@@ -239,6 +239,17 @@ def codex_cost_usd(model: str | None, input_t: int, cached_input_t: int, output_
|
|
|
239
239
|
return round((fresh * pi + cached_input_t * pci + output_t * po) / 1_000_000, 4)
|
|
240
240
|
|
|
241
241
|
|
|
242
|
+
_THREE_RATE_TABLES = {
|
|
243
|
+
"codex": CODEX_PRICING,
|
|
244
|
+
"grok": GROK_PRICING,
|
|
245
|
+
"kimi": KIMI_PRICING,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _three_rate_pricing(provider: str, model: str | None) -> tuple | None:
|
|
250
|
+
return _match_pricing(model, _THREE_RATE_TABLES.get(provider, {}))
|
|
251
|
+
|
|
252
|
+
|
|
242
253
|
def provider_cost_usd(
|
|
243
254
|
provider: str,
|
|
244
255
|
model: str | None,
|
|
@@ -247,12 +258,7 @@ def provider_cost_usd(
|
|
|
247
258
|
output_t: int,
|
|
248
259
|
) -> float | None:
|
|
249
260
|
"""Price a three-rate provider without fabricating an unknown model rate."""
|
|
250
|
-
|
|
251
|
-
"codex": CODEX_PRICING,
|
|
252
|
-
"grok": GROK_PRICING,
|
|
253
|
-
"kimi": KIMI_PRICING,
|
|
254
|
-
}
|
|
255
|
-
pricing = _match_pricing(model, tables.get(provider, {}))
|
|
261
|
+
pricing = _three_rate_pricing(provider, model)
|
|
256
262
|
if pricing is None:
|
|
257
263
|
return None
|
|
258
264
|
input_price, cached_price, output_price = pricing
|
|
@@ -264,6 +270,33 @@ def provider_cost_usd(
|
|
|
264
270
|
) / 1_000_000, 4)
|
|
265
271
|
|
|
266
272
|
|
|
273
|
+
def provider_billable_equivalent(
|
|
274
|
+
provider: str,
|
|
275
|
+
model: str | None,
|
|
276
|
+
input_t: int,
|
|
277
|
+
cached_input_t: int,
|
|
278
|
+
output_t: int,
|
|
279
|
+
) -> int | None:
|
|
280
|
+
"""세 단가 공급자의 과금 환산 토큰 — `claude_billable_equivalent` 와 같은 정의.
|
|
281
|
+
|
|
282
|
+
입력 단가를 1 로 두고 캐시 입력·출력을 그 공급자의 단가 비율로 환산해 더한다
|
|
283
|
+
(codex gpt-5.6: cached 0.1x, output 6x). 모델 단가가 표에 없으면 None —
|
|
284
|
+
비용과 마찬가지로 지어내지 않는다.
|
|
285
|
+
"""
|
|
286
|
+
pricing = _three_rate_pricing(provider, model)
|
|
287
|
+
if pricing is None:
|
|
288
|
+
return None
|
|
289
|
+
input_price, cached_price, output_price = pricing
|
|
290
|
+
if not input_price:
|
|
291
|
+
return None
|
|
292
|
+
fresh_input = max(0, input_t - cached_input_t)
|
|
293
|
+
return int(round(
|
|
294
|
+
fresh_input
|
|
295
|
+
+ cached_input_t * (cached_price / input_price)
|
|
296
|
+
+ output_t * (output_price / input_price)
|
|
297
|
+
))
|
|
298
|
+
|
|
299
|
+
|
|
267
300
|
def antigravity_cost_usd(model: str | None, input_t: int, output_t: int) -> float | None:
|
|
268
301
|
p = _match_pricing(model, GEMINI_PRICING)
|
|
269
302
|
if p is None:
|
|
@@ -198,7 +198,7 @@ That is the entire interactive flow. The wizard handles:
|
|
|
198
198
|
- base-ref pick + git rev-parse validation (skipped when reusing an active worktree),
|
|
199
199
|
- `implementation`-only sub-flow: approved-plan path (frontmatter `approved: true` check) + stage pick (`auto` = the earliest incomplete stage whose dependencies are satisfied, or a specific stage number). Implementer slots use role-count / role-model like every other role (`executor` is only a compatibility alias for `implementer`). When an approved plan is selected and a `## PLAN DECISION` sidecar carrying `Status: approved`, exported from the report — matching the plan on source-report·seq — is detected in that run's sibling `user-responses/`, the approve-confirm step expands to 3 options (`yes_apply` recommended: approve + apply the option as exported / `yes` approve only / `no` abort) — `yes_apply` validates the option against the plan's `optionCandidates` before applying it via the existing approval·option path,
|
|
200
200
|
- `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),
|
|
201
|
-
- launch selection after identity/worktree steps: one screen per static role, in profile order. A role that can run several instances (`max > 1`) is a checkbox step `role-models:<role>` (`multi: true`) — the number of models checked is the number of instances, there is no separate count question; the label states the profile range and recommended count, the recommended set
|
|
201
|
+
- launch selection after identity/worktree steps: one screen per static role, in profile order. A role that can run several instances (`max > 1`) is a checkbox step `role-models:<role>` (`multi: true`) — the number of models checked is the number of instances, there is no separate count question; the label states the profile range and recommended count, every executable candidate is listed on that one screen (defaults first, the recommended set flagged), and when the list exceeds the host's native checkbox limit the interaction plan is `numbered-multi` — render the whole list, never a shortlist or pages. An optional role (`min = 0`, e.g. critic) carries a `추가 안 함` row. A fixed single role (`min = max = 1`, e.g. report-writer) is a single pick `role-model:<role>:1`. current-session lead is this session and is listed on the confirmation summary, not as a wizard step. The wizard does not fork on defaults-vs-customize, does not show a provider roster multi-pick, and does not offer a separate implementer-provider pick. Dynamic verifiers are not chosen at launch. `--workers` is compatibility-only, not a launch picker. Repeated `--role-count` / `--role-model` tokens on `renderArgv` are intentional,
|
|
202
202
|
- **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 role-count·role-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,
|
|
203
203
|
- **re-verification scope (`reverify_scope_pick`, `implementation-planning` clarification re-runs only)** — asked right before `confirm` when the re-run is narrowable **or** an answered `C-NNN` traces to no stage. When every answered id traces to a stage: 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). When an id is unlinked, `auto` is omitted and the user names stages or picks `full`; that unlinked id does not freeze the run at full. 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. The confirmation block's `reverify-scope` line names unlinked ids as needing stage numbers, not as a forced full re-run,
|
|
204
204
|
- `release-handoff` PR template override + persist scope,
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
<button type="button" data-action="export-user-response">{{ t('base.export-my-answers') }}</button>
|
|
145
145
|
<button type="button" data-action="copy-user-response">{{ t('base.copy') }}</button>
|
|
146
146
|
<button type="button" data-action="dismiss-user-response" hidden>{{ t('base.dismiss') }}</button>
|
|
147
|
-
<p class="user-response-hint">{{ t('base.export-downloads') }} <code>user-response-{{ runMeta.task_type }}-{{ runMeta.seq }}.md</code>{{ t('base.drop-that-file-into') }} <code>
|
|
147
|
+
<p class="user-response-hint">{{ t('base.export-downloads') }} <code>user-response-{{ runMeta.task_type }}-{{ runMeta.seq }}.md</code>{{ t('base.drop-that-file-into') }} <code>{{ userResponseDir }}</code> {{ t('base.and-the-next-run-picks-your-answers-up-on-it') }}</p>
|
|
148
148
|
<pre id="user-response-output" aria-live="polite"></pre>
|
|
149
149
|
</footer>{% endif %}
|
|
150
150
|
<div class="back-to-top-wrap">
|