okstra 0.195.1 → 0.195.2

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.
@@ -944,6 +944,7 @@ Tokens used in each run are collected from lead/worker session transcripts and w
944
944
  - Antigravity CLI: the `usage` snapshot in the wrapper `<prompt>.status.json` — the runner records the last `usage` the `agy` stream reported (`result.usage` on a run that closed). The CLI writes no transcript under the home directory, and the worker `.log` is the stream rewritten as readable lines, not stream-json.
945
945
  - CLI execution evidence and token attribution are independent. A wrapper `.status.json` proves `not-started`, `started`, `exited`, `timeout`, or `failed` and supplies the worker's collection window; only a matching transcript with a final token snapshot — or, for a provider without a home transcript, the `usage` snapshot the runner wrote into that `.status.json` — proves attributable usage. If a wrapper exited successfully but no attributable transcript exists, the worker remains `source: "unavailable"` with `cliExecutionStatus: "exited"` and a reason instead of becoming zero usage or being described as never invoked.
946
946
  - Records billable-equivalent token math and USD cost estimates. It applies Anthropic billing ratios (`cache_creation_5m=1.25x`, `cache_creation_1h=2.0x`, `cache_read=0.1x`, `output=5x`). When the transcript provides separate `usage.cache_creation.ephemeral_5m_input_tokens` / `ephemeral_1h_input_tokens` values, they are counted separately.
947
+ - CLI providers (codex, grok, kimi) report cache reads inside their input count (`cached_input_tokens ⊂ input_tokens`). Their usage blocks carry the same keys as a Claude block — `totalTokens` with the cached part removed, `cacheReadTokens`, and `billableEquivalentTokens` normalized by that model's own rate ratios (`provider_billable_equivalent`) — so the report's cache-read and billable columns are filled for every agent. Their cost stays in `cliEstimatedCostUsd` and on the report's separate CLI row.
947
948
  - Pricing is centrally managed in `scripts/okstra_token_usage/pricing.py` (Claude and Gemini rate tables) and in each provider catalog's `ModelSpec(pricing=...)` (codex, grok, kimi — merged through `catalog_pricing`). Update it when model prices change. Model IDs that fail price matching are exposed to the user in `usageSummary.unmatchedModels`, preventing silent-zero incidents.
948
949
  - **Cost is always the public list price, regardless of how the account is billed.** The report answers "how much was consumed", not "what the invoice says": a model served under a ChatGPT/Claude subscription is still priced at its API rate, so every selectable catalog row must carry a price. The one row without a price is `codex-auto-review`, for which no public rate exists.
949
950
  - Project-wide historical usage is exposed through the read-only `okstra usage-report` command (`scripts/okstra_ctl/usage_report.py`) and the `okstra-usage` skill. It defaults to the whole current project's last 30 days and returns run coverage, raw and billable-equivalent tokens, known USD cost, CPU-sum milliseconds, and wall-clock milliseconds grouped by task type. Runs without usable Phase 7 usage are excluded from resource totals and reported through unavailable reason counts rather than treated as zero usage; unmatched model names remain visible when their tokens and time are included but their cost is not. Use `okstra-inspect` for one task's elapsed/context detail and `okstra-rollup` for task-group or project status/report digests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.195.1",
3
+ "version": "0.195.2",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.195.1",
3
- "builtAt": "2026-09-09T11:12:46.377Z",
2
+ "package": "0.195.2",
3
+ "builtAt": "2026-09-09T14:40:36.284Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -621,14 +621,14 @@
621
621
  "options": {
622
622
  "model": "{model_ref} — {display}",
623
623
  "skip": "추가 안 함{skip_warning}",
624
- "__free_input__": "직접 선택 (실행 가능한 전체 후보 {total}개에서 고르기)",
625
624
  "skip_warnings": {
626
625
  "critic": " — 차단 kind 에서 분석자 표가 1대1 동수면 가를 주체가 없어, 그 항목마다 승인 결정과 Blocks=approval clarification 행이 열립니다 (자동 tie-break 대신 사용자 질문. phase 는 계속 진행됩니다)"
627
626
  }
628
627
  },
629
628
  "labels": {
630
629
  "range": "허용 {minimum}..{maximum}개, 권장 {recommended}개",
631
- "exact": "정확히 {count}개"
630
+ "exact": "정확히 {count}개",
631
+ "single": "{role} 역할의 모델을 하나 고르세요 — 인스턴스는 1개입니다 ({range}). 1번 추천은 프로젝트 modelDefaults(없으면 카탈로그 기본값)이고, 이 역할을 빼려면 '추가 안 함' 입니다"
632
632
  },
633
633
  "errors": {
634
634
  "count_out_of_range": "{range} 골라야 합니다 — 고른 수 {count}개",
@@ -636,31 +636,6 @@
636
636
  "unknown_option": "목록에 없는 항목입니다: {values}"
637
637
  },
638
638
  "echo_variants": {
639
- "custom": "role-models: (전체 후보에서 직접 선택)",
640
- "skipped": "role-models: (추가 안 함)"
641
- }
642
- },
643
- "role_models_custom": {
644
- "label": "{role} 역할의 모델을 전체 후보에서 고르세요 — 고른 만큼 인스턴스를 띄웁니다 ({range})",
645
- "echo_template": "role-models: {value}",
646
- "options": {
647
- "model": "{model_ref} — {display}",
648
- "skip": "추가 안 함{skip_warning}",
649
- "skip_warnings": {
650
- "critic": " — 차단 kind 에서 분석자 표가 1대1 동수면 가를 주체가 없어, 그 항목마다 승인 결정과 Blocks=approval clarification 행이 열립니다 (자동 tie-break 대신 사용자 질문. phase 는 계속 진행됩니다)"
651
- }
652
- },
653
- "labels": {
654
- "range": "허용 {minimum}..{maximum}개, 권장 {recommended}개",
655
- "exact": "정확히 {count}개"
656
- },
657
- "errors": {
658
- "count_out_of_range": "{range} 골라야 합니다 — 고른 수 {count}개",
659
- "min_one_required": "모델을 {range} 고르세요 (이 역할을 빼려면 '추가 안 함')",
660
- "unknown_option": "목록에 없는 항목입니다: {values}"
661
- },
662
- "echo_variants": {
663
- "custom": "role-models: (전체 후보에서 직접 선택)",
664
639
  "skipped": "role-models: (추가 안 함)"
665
640
  }
666
641
  }
@@ -191,6 +191,7 @@ def render_v2_html_view(
191
191
  response_js = (root / "report.js").read_text(encoding="utf-8")
192
192
  base_js = (root / "html/assets/base.js").read_text(encoding="utf-8")
193
193
  source_data = final_report_data_path(Path(run_meta.source_report)).as_posix()
194
+ user_responses_dir = user_responses_dir_for_report(data_path.resolve())
194
195
  context = {
195
196
  **view.context,
196
197
  "runMeta": run_meta,
@@ -215,6 +216,11 @@ def render_v2_html_view(
215
216
  # Every task type ends with the same run-cost section, so it is bound
216
217
  # here rather than in ten view models that would each rebuild it.
217
218
  "runUsage": run_usage(data),
219
+ # 바닥글이 "여기에 두면" 이라고 가리키는 디렉터리. 상대 경로
220
+ # `runs/<type>/user-responses/` 는 task 디렉터리 기준이라는 말이 없어
221
+ # 프로젝트 루트에서 찾으면 없고, implementation 은 stage 아래라 그 경로
222
+ # 자체가 틀렸다(실측 2026-09-09) — 보고서 파일에서 계산한 절대 경로를 찍는다.
223
+ "userResponseDir": user_responses_dir.as_posix(),
218
224
  "executionRoles": data.get("executionRoles") or [],
219
225
  "css": (root / "html/assets/base.css").read_text(encoding="utf-8"),
220
226
  "js": response_js + "\n" + base_js,
@@ -223,8 +229,8 @@ def render_v2_html_view(
223
229
  document = env.get_template(route.template_name).render(**context)
224
230
  document = inject_report_index(document, label=translate("base.contents"))
225
231
  output_path.write_text(document, encoding="utf-8")
226
- if context["clarificationItems"] or context.get("directionSelection"):
227
- # The footer tells the reader to drop the exported file here, so the
228
- # directory has to exist before they go looking for it.
229
- user_responses_dir_for_report(data_path).mkdir(parents=True, exist_ok=True)
232
+ # The footer tells the reader to drop the exported file here, so the
233
+ # directory has to exist before they go looking for it on every report,
234
+ # not only those with clarification rows: the footer is rendered on all.
235
+ user_responses_dir.mkdir(parents=True, exist_ok=True)
230
236
  return output_path
@@ -49,6 +49,24 @@ def _agent_label(row: Mapping[str, Any]) -> str:
49
49
  return _AGENT_LABELS.get(value.lower(), value)
50
50
 
51
51
 
52
+ def _row_model(row: Mapping[str, Any], usage: Mapping[str, Any]) -> str:
53
+ """행이 이름한 모델, 없으면 사용량 수집기가 트랜스크립트에서 읽은 모델.
54
+
55
+ `/okstra-run` 으로 현재 세션에서 도는 리드는 prepare 시점에 모델을 모른다 —
56
+ 매니페스트 `leadModel` 과 team-state `lead.model` 이 문자 그대로 `unknown`
57
+ 이다. 수집기는 그 세션의 jsonl 에서 `claude-opus-5` 를 읽어 `leadUsage.model`
58
+ 에 적어 두므로, 표는 그 값을 쓴다(실측 2026-09-09: 리드 행 모델 `unknown`).
59
+ """
60
+ named = row.get("model") or row.get("modelExecutionValue")
61
+ if isinstance(named, str) and named.strip() and named.strip() != "unknown":
62
+ return named.strip()
63
+ if usage.get("source") != "unavailable":
64
+ measured = usage.get("model")
65
+ if isinstance(measured, str) and measured.strip():
66
+ return measured.strip()
67
+ return "unknown"
68
+
69
+
52
70
  def _execution_row(
53
71
  row: Mapping[str, Any],
54
72
  *,
@@ -65,7 +83,7 @@ def _execution_row(
65
83
  result = {
66
84
  "agent": _agent_label(row),
67
85
  "role": role,
68
- "model": _text(row.get("model") or row.get("modelExecutionValue"), "unknown"),
86
+ "model": _row_model(row, source),
69
87
  "status": status,
70
88
  "summary": _text(
71
89
  row.get("summary") or row.get("reason"),
@@ -132,13 +132,23 @@ def next_prompt(state: WizardState) -> Prompt:
132
132
 
133
133
 
134
134
  def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
135
+ """호스트 네이티브 선택기 한도에 맞춘 화면.
136
+
137
+ 단일 선택이 한도를 넘으면 쪽으로 나눈다(`present_picker`). 체크박스(`multi`)는
138
+ 나누지 않는다 — 네이티브 체크박스에 못 실으면 `CapabilityInteractionPort.plan`
139
+ 이 `numbered-multi` 로 내려 전체 목록을 한 번에 보인다. 종전엔 체크박스도
140
+ 한 줄씩 토글하는 쪽으로 내렸는데, claude-code 한도 4 에서 후보 12개는 쪽당
141
+ 2개가 됐고, 쪽 사본이 추천 표시를 단 채 단일 선택이 돼 `Prompt` 의 추천
142
+ 불변식(단일 선택은 추천 정확히 하나)에 걸려 화면이 열리지 않았다(실측
143
+ 2026-09-09, verifier 전체 후보 화면).
144
+ """
135
145
  if "native_single_select" not in state.available_functions:
136
146
  return prompt
137
147
  if prompt.kind == "pick_group":
138
148
  if _interaction_plan(state, prompt).kind == "native-group":
139
149
  return prompt
140
150
  prompt = prompt.questions[0]
141
- if _interaction_plan(state, prompt).kind == "native-multi":
151
+ if prompt.multi:
142
152
  return prompt
143
153
  limit = default_host_registry().resolve(state.host_runtime).interaction().native_option_limit
144
154
  return present_picker(state, prompt, limit=limit)
@@ -377,9 +387,8 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
377
387
  if prompt.kind == "pick_group":
378
388
  return _submit_group(state, prompt, value)
379
389
  if _is_role_selection_step(prompt.step):
380
- # 화면은 호스트 한도에 맞춰 쪽으로 잘린 사본일 수 있다(`present_picker`).
381
- # 답은 잘리지 않은 원본의 선택지로 검증한다 — 체크박스의 CSV 는 여러
382
- # 쪽에 걸쳐 고른 값이다.
390
+ # 고정 단일 역할의 화면은 호스트 한도에 맞춰 쪽으로 잘린 사본일 수
391
+ # 있다(`present_picker`). 답은 잘리지 않은 원본의 선택지로 검증한다.
383
392
  original = next_role_prompt(state)
384
393
  if original is not None and original.step == prompt.step:
385
394
  prompt = original
@@ -1,28 +1,28 @@
1
- """호스트 선택기 한도 안에서 원래 선택지를 보존하는 화면 변환."""
2
- from dataclasses import replace
1
+ """호스트 선택기 한도 안에서 단일 선택의 원래 선택지를 보존하는 나누기.
3
2
 
4
- from okstra_ctl.wizard_stage_intent import WHOLE_TASK_STAGE
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 ALL_STAGES, PICK_TYPE_CUSTOM
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=(" " if option.value in selected else "") + option.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
- reserve = 2 if prompt.multi else 1
24
- paged = len(options) + int(prompt.multi) > limit
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, multi=False)
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
- if not prompt.multi:
56
- state.picker_offsets.pop(prompt.step, None)
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
- 체크박스 화면은 두 장이다. `role-models:<role>` 기본 후보(프로젝트
11
- `modelDefaults` 또는 카탈로그 기본값) 싣고 권장 수만큼 앞줄을 추천으로 표시한다
12
- 호스트 네이티브 선택기의 옵션 한도(claude-code 4, codex 3, grok 15) 안에
13
- 들어가야 체크박스로 렌더되기 때문이다. 마지막 "직접 선택" 이
14
- `role-models-custom:<role>` 열고, 거기에 실행 가능한 전체 후보가 실린다.
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 (_CUSTOM_PREFIX, _MODELS_PREFIX, _SINGLE_PREFIX):
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` / `role_models_custom` 문구. 허용 범위 문장은 `range` 키다."""
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
- """역할 하나의 모델 화면. `full` 이면 전체 후보, 아니면 기본 후보 + 직접 선택.
217
+ """역할 하나의 모델 화면 실행 가능한 전체 후보, 기본 후보가 앞이다.
240
218
 
241
219
  추천은 권장 수만큼의 앞줄이다 — 프로젝트 `modelDefaults`(없으면 카탈로그
242
220
  기본값) 순서가 그 근거다. 권장이 0인 선택 역할은 "추가 안 함" 이 추천이다.
243
221
  """
244
222
  role = requirement.role
245
223
  pool = context.pool
246
- defaults, everything = _available_role_models(
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
- custom = full
253
- if not full:
254
- shown = defaults[: _shortlist_budget(state, requirement)]
255
- # 기본 후보만으로 최소 수를 채우면 줄인 목록은 답이 될 수 없다 —
256
- # 같은 화면에 전체 후보를 싣는다.
257
- if len(shown) < min(requirement.min_count, len(everything)) or not shown:
258
- full = True
259
- if full:
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(shown):
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=requirement.max_count > 1,
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
- prompt_key = (
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((_MODELS_PREFIX, _CUSTOM_PREFIX)):
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:", "role-models-custom:"))
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")
@@ -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
- block["cliTotalTokens"] = totals.get("totalTokens", 0) or 0
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 {"codex", "grok", "kimi"}:
680
- cost = provider_cost_usd(
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
- totals.get("cachedInputTokens", 0) or 0,
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
- tables = {
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 is flagged, and the last row (`직접 선택`) opens `role-models-custom:<role>` with every executable candidate. 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,
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>runs/{{ runMeta.task_type }}/user-responses/</code> {{ t('base.and-the-next-run-picks-your-answers-up-on-it') }}</p>
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">