okstra 0.190.0 → 0.191.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -937,11 +937,15 @@ Tokens used in each run are collected from lead/worker session transcripts and w
937
937
  - Helper CLI: `scripts/okstra-token-usage.py`
938
938
  - Collection sources:
939
939
  - Claude lead/workers: per-message `message.usage` in `~/.claude/projects/<cwd-as-dashes>/<sessionId>.jsonl` or `~/.claude/projects/<cwd-as-dashes>/<lead-session>/subagents/agent-a<worker-name>-<hash>.jsonl`. Worker names are recovered from nested-subagent filenames, and only the directory for the current run's `team-state.lead.sessionId` is counted.
940
- - Codex CLI: final `total_token_usage.total_tokens` in `~/.agent/sessions/Y/M/D/rollout-*.jsonl`
940
+ - Codex CLI: final `total_token_usage.total_tokens` in `~/.codex/sessions/Y/M/D/rollout-*.jsonl` (`$CODEX_HOME/sessions` first, then `~/.agent/sessions`). An in-session codex lead (`entryMode: current-session`) opened its rollout before the run and keeps it after, so its cumulative snapshot is the whole session; the lead is counted as the sum of `last_token_usage` over the `token_count` events inside the run window (`codex_session_window_total`), and a session that started before the window still qualifies when it wrote inside it (`find_codex_sessions(active_before_start=True)`).
941
+ - Grok CLI: the sum of the `usage` records in `~/.grok/sessions/<percent-encoded cwd>/<sessionId>/updates.jsonl` — one record is one prompt's usage (`numTurns` model calls, input re-sent each call), not a running total, so the last record alone is only the last prompt. grok (and kimi) run *inside* the stage worktree, so the directory is encoded from the worktree path, not the project root. An in-session grok lead is counted over the records inside the run window (`grok_session_window_total`), and a session that started before the window still qualifies when it wrote inside it (`find_grok_sessions(active_before_start=True)`).
942
+ - Claude workers on a non-Claude host: the wrapper runs `claude -p --session-id <id>`, and the transcript is `~/.claude/projects/<cwd-as-dashes>/<id>.jsonl`, found by the id `workerDispatches[].sessionId` recorded at dispatch.
943
+ - **Attribution key is the dispatch ledger, not the roster row.** `workers[].promptPath` names one prompt — the first dispatch — and v2 lead events carry no `workerId`. Every re-dispatch (reverify, critic-gap, plan-verify, report-writer re-author) is a new wrapper run with its own prompt, `.status.json`, and transcript, and only `team-state.workerDispatches[]` lists them all (`dispatch_state.worker_dispatch_records`, keyed by `workerId` or the last segment of `assignmentRef`). The collector takes one wrapper window per record, unions the transcripts found under each record's `worktreePath` and the project root, and sums them; wall-clock is the sum of the wrapper windows. Observed before this rule (2026-09-08, a codex-host planning run): codex counted 1 of 5 sessions, grok 0 of 1, claude 0 of 7.
941
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.
942
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.
943
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.
944
- - Pricing is centrally managed in `scripts/okstra_token_usage/pricing.py`. Update it when model prices change. Model IDs that fail price matching are exposed to the user in `usageSummary.unmatchedModels`, preventing silent-zero incidents.
947
+ - 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
+ - **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.
945
949
  - 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.
946
950
  - **Incremental scan cache (P6)**: To avoid rescanning session jsonl files, a per-file byte cursor and the extracted usage events before windowing are stored in `$OKSTRA_HOME/cache/token-usage/<transcript-dir>/<sessionId>.json` (`scripts/okstra_token_usage/cursor.py`). The run window (since/until) is reevaluated over events on every invocation, so even if a rerun narrows the window, the total matches a full scan. The cache is derived data; identifier mismatch, truncation, or corruption automatically falls back to a full rescan, and `okstra-token-usage.py --no-cache` forces a bypass.
947
951
  - **Phase timeline (P0 instrumentation)**: The collector extracts `PROGRESS: phase-*` checkpoint lines (see "Progress reporting" in prompts/lead/okstra-lead-contract.md) from the lead session jsonl scoped to the run window, and records them in team-state as a `phaseTimeline` block (`{source, phases: [{phase, firstAt, lastAt, markerCount, wallMsToNext}]}`) (`scripts/okstra_token_usage/collect.py :: phase_timeline`). This provides measurement points for per-phase wall-clock time within the run and is consumed by the "Per-run phase breakdown" in the `okstra-inspect` time facet. Runs without markers explicitly report that measurement is unavailable with `phases: []`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.190.0",
3
+ "version": "0.191.1",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.190.0",
3
- "builtAt": "2026-09-08T19:21:43.911Z",
2
+ "package": "0.191.1",
3
+ "builtAt": "2026-09-08T22:08:39.785Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
  import argparse
26
26
  import hashlib
27
27
  import json
28
+ import re
28
29
  import os
29
30
  import sys
30
31
  from pathlib import Path
@@ -199,17 +200,31 @@ def _translation_block(rows: list[str]) -> str:
199
200
  return "\n".join(rows).strip().replace("\\`", "`")
200
201
 
201
202
 
203
+ _T_HEADING_RE = re.compile(r"^#{1,6}\s+T-\d{3}\b")
204
+
205
+
202
206
  def _translation_blocks(path: Path) -> list[str]:
203
207
  text = path.read_text(encoding="utf-8")
204
208
  blocks: list[str] = []
205
209
  current: list[str] | None = None
206
- for row in text.splitlines():
210
+ for number, row in enumerate(text.splitlines(), start=1):
211
+ # 헤딩 수준이 다른 `### T-NNN` 은 블록 경계로 안 보여 본문에 묻히고, 그러면
212
+ # 블록 수 불일치라는 엉뚱한 메시지가 났다(실측 2026-09-08: id 164개 전부
213
+ # 일치, 틀린 것은 `#` 개수뿐). 경계처럼 생긴 줄은 여기서 이름을 대고 거절한다.
214
+ if _T_HEADING_RE.match(row) and not row.startswith("## T-"):
215
+ raise SystemExit(
216
+ f"error: translation heading must be level 2 — line {number} is "
217
+ f"{row.split()[0]!r}, expected '## T-NNN' ({path.name})"
218
+ )
207
219
  if row.startswith("## T-"):
208
220
  if current is not None:
209
221
  blocks.append(_translation_block(current))
210
222
  expected = f"## T-{len(blocks) + 1:03d}"
211
223
  if row != expected:
212
- raise SystemExit(f"error: expected translation heading {expected}")
224
+ raise SystemExit(
225
+ f"error: expected translation heading {expected} at line {number}, "
226
+ f"found {row!r} ({path.name})"
227
+ )
213
228
  current = []
214
229
  elif current is not None:
215
230
  current.append(row)
@@ -232,8 +247,18 @@ def cmd_write(args: argparse.Namespace) -> int:
232
247
  _validate_source_payload(source, expected, args.source_digest)
233
248
  sources = expected["strings"]
234
249
  translated = _translation_blocks(Path(args.translations))
235
- if len(translated) != len(sources) or any(not value for value in translated):
236
- raise SystemExit("error: translation blocks must match every T-NNN item")
250
+ if len(translated) != len(sources):
251
+ raise SystemExit(
252
+ f"error: translation blocks must match every T-NNN item — "
253
+ f"{len(translated)} blocks in {Path(args.translations).name}, "
254
+ f"{len(sources)} items in the translation source"
255
+ )
256
+ empty = [f"T-{index + 1:03d}" for index, value in enumerate(translated) if not value]
257
+ if empty:
258
+ raise SystemExit(
259
+ "error: translation blocks must match every T-NNN item — empty: "
260
+ + ", ".join(empty[:10])
261
+ )
237
262
  lang = expected["lang"]
238
263
  sidecar = translation_sidecar_path(data_path, lang)
239
264
  if sidecar.is_symlink():
@@ -89,7 +89,7 @@ Read the worker result files generated in Phase 4/5 and extract individual findi
89
89
  - Same semantics but disjoint ticket sets → separate groups (do NOT over-merge across tickets).
90
90
  - Only one worker confirms a finding → one single-source group.
91
91
  4. When grouping is ambiguous, prefer splitting over merging (avoid over-merging). Semantic matching, ticket-set equality, and evidence interpretation remain lead judgments; the engine does not perform fuzzy matching or decide whether evidence is credible.
92
- 5. Author the fixed grouping Markdown accepted by `okstra convergence prepare-groups --run-manifest <run-manifest> --input <grouping.md>`, then run that command. Python owns the artifact identifier, target path, schema version, task identity, run-manifest reference, and every participant reference. Each Markdown group records ticket IDs, origin worker and evidence, discovering workers, source worker item IDs, and optional captured evidence. An analysis sidetrack with no ticket uses an empty `Tickets:` value, never a placeholder. Use the ordered functional roster: finding workers have the `analysis` audience, the report author has `report-writer`, and the lead uses `lead`. A lead source never votes. Never infer live evidence or functional scope from wording, provider, model, or execution label.
92
+ 5. Author the fixed grouping Markdown accepted by `okstra convergence prepare-groups --run-manifest <run-manifest> --input <grouping.md>`, then run that command. Python owns the artifact identifier, target path, schema version, task identity, run-manifest reference, and every participant reference. Each Markdown group records ticket IDs, origin worker and evidence, discovering workers, source worker item IDs, and optional captured evidence. An analysis sidetrack with no ticket uses an empty `Tickets:` value, never a placeholder. Use the ordered functional roster: finding workers have the `analysis` audience, the report author has `report-writer`, and the lead uses `lead`. A lead source never votes. For `implementation` runs the convergence sources are the verifier-role results only — the executor's result is deliverable evidence, not a convergence source (**Enforced:** `_validate_worker_execution_identity` in `scripts/okstra_ctl/convergence_engine.py` rejects an `implementer` source with `analysis audience source role is not allowed`). Never infer live evidence or functional scope from wording, provider, model, or execution label.
93
93
 
94
94
  The command sets each worker's paired `participantRef` and `sourceRoleExecutionRef` from the run manifest's canonical role state. It sets `sourceRoleExecutionRef` to the selected source `RoleExecution` row's `roleExecutionRef`, not that row's `sourceRoleExecutionRef` field.
95
95
  6. Do not write a queue or classification in this grouped-input artifact. `okstra convergence seed` classifies Round 0 the same way in both modes: a group whose sources are **two or more distinct role executions** becomes `full-consensus` immediately, and only single-source groups enter the working queue. Independent co-derivation is already cross-verification — the adversarial burden of proof targets single-source claims, not a finding two roles reached on their own. A source is counted once per analysis worker, and one analysis worker is exactly one `sourceRoleExecutionRef` — the same identity the reverify roster uses for independence — so two roles held by one provider count as two and no role can count twice. **Enforced:** `_parse_workers` rejects a duplicate `workerId` and `_validate_worker_execution_identity` rejects a duplicate `sourceRoleExecutionRef`, both in `scripts/okstra_ctl/convergence_engine.py`. Semantic grouping merges provenance only; it does not decide a single-source finding is reliable. Section 6 never enters the grouped input.
@@ -146,7 +146,7 @@ Do NOT replace them with prose ("Now I'm starting Phase 2..."), do NOT skip a ch
146
146
 
147
147
  `okstra-run` surfaces these lines to the user directly; `okstra lead-progress append` persists them in the selected adapter's declared conformance evidence/event source for post-hoc retrieval.
148
148
 
149
- **Enforcement:** the Phase 7 validator (`validators/validate-run.py` → `validate_session_conformance.py`) reads the selected adapter's declared conformance evidence/event source within the run window and fails the run as `contract-violated` when a required checkpoint is missing — including the per-worker `phase-4-dispatch` / `phase-5-collect` lines (which must name each worker's role) and the `phase-batch-cleanup` lines that MUST precede the first `phase-5.5-convergence` round and the `phase-6-synthesis` report-writer dispatch. When the plan-body state file records two or more rounds, `_check_plan_verify_cleanup_checkpoints` additionally requires a `phase-5.5.9-plan-verify` line per round and a `phase-batch-cleanup` between consecutive rounds. For `implementation`, `_check_progress_checkpoints` additionally requires the `phase-5-stage` announcement once any worker is dispatched, and the `phase-5-stage-complete` line once an implementer worker completed. For activity-contract-v1 planning, `_check_activity_contract` validates the structured worker pairs, verification and self-fix counts, user-decision references, and `A-NNN` ordering. `phase-7-teardown` and `complete` fire after validation and are not checked.
149
+ **Enforcement:** the Phase 7 validator (`validators/validate-run.py` → `validate_session_conformance.py`) reads the selected adapter's declared conformance evidence/event source within the run window and fails the run as `contract-violated` when a required checkpoint is missing — including the per-worker `phase-4-dispatch` / `phase-5-collect` lines (which must name each worker's role) and the `phase-batch-cleanup` lines that MUST precede the first `phase-5.5-convergence` round and the `phase-6-synthesis` report-writer dispatch. When the plan-body state file records two or more rounds, `_check_plan_verify_cleanup_checkpoints` additionally requires a `phase-5.5.9-plan-verify` line per round and a `phase-batch-cleanup` between consecutive rounds. For `implementation`, `_check_progress_checkpoints` additionally requires the `phase-5-stage` announcement once any worker is dispatched, and the `phase-5-stage-complete` line once an implementer worker completed **and** this stage's carry sidecar exists (`_stage_carry_persisted`) — a stage whose carry was withheld (`FAIL` / non-result) is not asked for the line. For activity-contract-v1 planning, `_check_activity_contract` validates the structured worker pairs, verification and self-fix counts, user-decision references, and `A-NNN` ordering. `phase-7-teardown` and `complete` fire after validation and are not checked.
150
150
 
151
151
  ## Asking the user (BLOCKING)
152
152
 
@@ -115,11 +115,25 @@ For `request_user_input`, send one to three questions. Each question carries `id
115
115
  For a `host-text` mapping, render each numbered item as its option label followed by its description verbatim; preserve every item and its order. The next user message is the raw answer: do not translate a number such as `1`, a CSV reply such as `1, 3`, an option label, or an option value before `okstra wizard step`. For `sequential-group`, collect one raw reply per question in order and build one compact JSON object keyed by the corresponding `questions[].step`; the wizard owns all normalization.
116
116
 
117
117
 
118
+ ### Runtime-generated selectable screens
119
+
120
+ `wizard/engine.py` adapts choice screens before returning `next` when the session declares `native_single_select`. `wizard/picker_navigation.py` preserves all original choices while paging long lists, collecting multi-selection through toggle/complete choices, and disambiguating duplicate labels. Oversized or unsupported groups are presented one member at a time. These paths are exercised by `tests/domain/wizard/test_picker_navigation.py` and `test_role_model_selection.py`.
121
+
122
+ Render exactly the returned current screen using its `interaction.kind`, including navigation and completion options. Submit their original values through `okstra wizard step` like other options. The runtime keeps navigation and partial selections in the state file and does not submit the underlying workflow decision until selection is complete. Do not reconstruct the full model list, perform pagination yourself, or replace model choices with a request to type `provider/model`. A repeated step ID after navigation or a toggle is the next screen, not a duplicated question; render that newly returned screen once.
123
+
124
+ If the runtime still returns a numbered interaction when the user requires a selector, preserve the state and check installation and live capabilities. Do not invent a text-input exception. Text steps remain text steps; a direct-input choice is selected through the picker before the runtime asks for the custom value.
125
+
126
+ ### Plan decisions and execution permissions
127
+
128
+ Classify the requested action, not the word "approval". The wizard's `approve_plan_confirm` is a workflow decision about adopting the selected plan; it is a `pick` prompt built by `_build_approve_plan_confirm` in `wizard/steps_plan.py`. Render its existing options through the available native selector, just like task type and plan selection. Explain the effect of each option in the question tool and map the selected label back to its original value (`yes`, `no`, or `yes_apply` when offered). Recording that decision in okstra artifacts does not turn the question into a host permission request. Do not ask the user to type an approval word or switch to prose merely because this step is called approval.
129
+
130
+ Host permission requests concern execution privileges, sandbox escalation, or access to a protected resource. Use the host's permission mechanism for those requests; a wizard answer does not grant those privileges. If a subsequent command requires such permission, handle it separately at that command.
131
+
118
132
  ### Client-aware question tool selection
119
133
 
120
134
  Before intersecting `semanticFunctions` with live capabilities, prefer `request_user_input` when the current session permits that call. Use its live restrictions rather than assuming it is always Plan-only: Codex CLI can enable it in Default mode with `default_mode_request_user_input`. The JSON mapping above remains the terminal picker contract.
121
135
 
122
- Use `request_user_input_async` only when the current client explicitly supports interactive asynchronous question cards, such as the Codex desktop app, and the synchronous tool is unavailable. Callable does not mean selectable: the Codex terminal (`codex-tui`, session source `cli`) can accept an asynchronous question but render it as a plain bulleted agent message. Do not count that terminal delivery as `native_single_select` or `native_question_group`. For a supported desktop client, replace the `function` of both native entries above with `request_user_input_async` and use the mapping below. Keep the conservative `nativeLimits` and the numbered mapping for multi-selection.
136
+ Use `request_user_input_async` only when the current client explicitly supports interactive asynchronous question cards, such as the Codex desktop app, and the synchronous tool is unavailable. Callable does not mean selectable: the Codex terminal (`codex-tui`, session source `cli`) can accept an asynchronous question but render it as a plain bulleted agent message. Do not count that terminal delivery as `native_single_select` or `native_question_group`. For a supported desktop client, replace the `function` of both native entries above with `request_user_input_async` and use the mapping below. Keep the declared `nativeLimits`; the runtime generates the selectable screens within those limits.
123
137
 
124
138
  If the user requested a selectable interface in the terminal and `request_user_input` is unavailable, keep the current wizard step pending instead of printing its options. `okstra install` enables `features.default_mode_request_user_input` when a Codex home exists, through `ensureCodexQuestionPicker` in `src/lib/host-config.mts`. Restart or resume the Codex session after installation and reread this relay. Changing the feature does not replace the current session's tool catalog. Preserve the existing wizard state-file path and call `okstra wizard step --state-file <existing-path> --no-submit` after resuming; do not initialize a replacement wizard or submit a guessed answer. If installation reports a configuration shape it cannot safely edit, its recovery command is `codex features enable default_mode_request_user_input`. Apply a configuration change only when authorized by the user, otherwise present the recovery command. The feature's availability is reported by `codex features list`; older clients without it need a client update or a mode in which the synchronous tool is permitted.
125
139
 
@@ -21,11 +21,17 @@ import okstra_ctl.model_discovery as model_discovery
21
21
  # `gpt-5.6` is not a slug that catalog offers at all, so it is gone from here;
22
22
  # its rate moved to `_LEGACY_CODEX_PRICING` so past runs still price.
23
23
  CODEX = {
24
- # ChatGPT-account hosts serve these models without per-token billing.
25
- "gpt-6-astra": ModelSpec("gpt-6-astra", "gpt-6-astra", "gpt-6-astra"),
26
- "gpt-5.6-sol": ModelSpec("gpt-5.6-sol", "gpt-5.6-sol", "gpt-5.6-sol"),
27
- "gpt-5.6-terra": ModelSpec("gpt-5.6-terra", "gpt-5.6-terra", "gpt-5.6-terra"),
28
- "gpt-5.6-luna": ModelSpec("gpt-5.6-luna", "gpt-5.6-luna", "gpt-5.6-luna"),
24
+ # 비용은 계정이 구독이든 API 공개 API 단가(입력·캐시 입력·출력 USD/1M)로
25
+ # 추정한다 — 리포트가 답하는 것은 "얼마나 썼는가" 이지 "청구서에 얼마가
26
+ # 찍히는가" 아니다. 단가를 비우면 그 워커의 비용이 `--` 로 빠지고, 접두어
27
+ # 폴백(`_LEGACY_CODEX_PRICING`)에 걸리면 terra·luna 가 sol 단가로 과대
28
+ # 계상된다(실측 2026-09-08). 출처: OpenAI 표준 등급, 2026-09 기준
29
+ # (morphllm.com/openai-api-pricing, cloudzero.com/blog/openai-pricing,
30
+ # layer3labs.io/guides/gpt-6-astra-api-pricing).
31
+ "gpt-6-astra": ModelSpec("gpt-6-astra", "gpt-6-astra", "gpt-6-astra", pricing=(10.0, 1.0, 50.0)),
32
+ "gpt-5.6-sol": ModelSpec("gpt-5.6-sol", "gpt-5.6-sol", "gpt-5.6-sol", pricing=(5.0, 0.50, 30.0)),
33
+ "gpt-5.6-terra": ModelSpec("gpt-5.6-terra", "gpt-5.6-terra", "gpt-5.6-terra", pricing=(2.0, 0.20, 12.0)),
34
+ "gpt-5.6-luna": ModelSpec("gpt-5.6-luna", "gpt-5.6-luna", "gpt-5.6-luna", pricing=(0.20, 0.02, 1.20)),
29
35
  # picker 에서는 감춘다. 엔트리는 남긴다 — 과거 run 의 토큰 사용량을
30
36
  # 정산할 때 pricing 을 이 표에서 찾는다.
31
37
  "gpt-5.4-mini": ModelSpec("gpt-5.4-mini", "gpt-5.4-mini", "gpt-5.4-mini", pricing=(0.75, 0.075, 4.50), selectable=False),
@@ -19,9 +19,12 @@ from okstra_ctl.domain.worker_stream import content_block_events
19
19
  KIMI = {
20
20
  "kimi-k3": ModelSpec("kimi-k3", "Kimi K3", "kimi-k3", aliases=("kimi k3",), pricing=(3.00, 0.30, 15.00)),
21
21
  # picker 에서는 감춘다: kimi-k3 와 표시명·실체가 같아 목록에 두 줄로 뜬다.
22
- "k3": ModelSpec("k3", "Kimi K3", "k3", selectable=False),
22
+ # 단가는 kimi-k3 같다 같은 모델의 다른 슬러그이고, `_match_pricing` 은
23
+ # `kimi-k3` 키를 `k3` 관측값에 맞추지 못하므로 행마다 값을 둔다. k3-256k 는
24
+ # Moonshot 이 별도 단가를 공개하지 않아 K3 단가를 쓴다(2026-09-08 확인).
25
+ "k3": ModelSpec("k3", "Kimi K3", "k3", pricing=(3.00, 0.30, 15.00), selectable=False),
23
26
  # picker 에서는 감춘다(사유는 codex 의 gpt-5.4-mini 와 동일).
24
- "k3-256k": ModelSpec("k3-256k", "Kimi K3 256K", "k3-256k", aliases=("kimi k3 256k",), selectable=False),
27
+ "k3-256k": ModelSpec("k3-256k", "Kimi K3 256K", "k3-256k", aliases=("kimi k3 256k",), pricing=(3.00, 0.30, 15.00), selectable=False),
25
28
  }
26
29
 
27
30
 
@@ -2420,9 +2420,15 @@ def _validate_worker_execution_identity(
2420
2420
  )
2421
2421
  allowed_roles = _AUDIENCE_SOURCE_ROLES[worker["audience"]]
2422
2422
  if source.role not in allowed_roles:
2423
+ hint = ""
2424
+ if source.role == "implementer":
2425
+ hint = (
2426
+ " — the executor's result is deliverable evidence, not a "
2427
+ "convergence source; group the verifier results instead"
2428
+ )
2423
2429
  raise ConvergenceContractError(
2424
2430
  f"{worker['audience']} audience source role is not allowed: "
2425
- f"{source.role}"
2431
+ f"{source.role} (allowed: {', '.join(sorted(allowed_roles))}){hint}"
2426
2432
  )
2427
2433
  source_refs = [worker["sourceRoleExecutionRef"] for worker in workers]
2428
2434
  if len(source_refs) != len(set(source_refs)):
@@ -631,7 +631,17 @@ def _dispatch_cli_wrapper_batch(
631
631
  contract is not None and contract[1].mutation_audit == "batch"
632
632
  for contract in contracts
633
633
  )
634
- if needs_batch and baseline_snapshot is None:
634
+ # 재시도 라운드는 baseline 다시 찍는다. 쓰기 계약은 attempt 소유라
635
+ # (`_persisted_write_contract`) attempt 2 의 정책 digest 는 attempt 1 과
636
+ # 다른데, 첫 라운드의 baseline 을 그대로 쓰면 마감의
637
+ # `_validate_snapshot_authority` 가 `policyDigests` 불일치로 죽고 attempt-N
638
+ # 사이드카는 끝내 안 써진다(실측 2026-09-08, translator attempt 2:
639
+ # 산출물 완전·exit 0 인데 dispatcher 예외). 앞 라운드에서 완주한 동료의
640
+ # 변경은 그 attempt 의 감사가 이미 판정했으므로 새 baseline 에 흡수돼도
641
+ # 맞다 — 재시도는 `failed-no-mutation` 뒤에만 오므로(`retry_allowed`)
642
+ # 미해결 변경이 baseline 으로 세탁되지는 않는다.
643
+ retry_round = any(attempt > 1 for _job, attempt in prepared)
644
+ if needs_batch and (baseline_snapshot is None or retry_round):
635
645
  baseline_snapshot = _mutation_snapshot(
636
646
  plan, policies, tuple(job for job, _ in prepared)
637
647
  )
@@ -1692,16 +1692,39 @@ def worker_session_ids(
1692
1692
  here, the same way `add_observed_session` keeps it for the keys it appends to.
1693
1693
  """
1694
1694
  session_ids: list[str] = []
1695
+ for record in worker_dispatch_records(team_state, worker_id):
1696
+ session_id = str(record.get("sessionId") or "").strip()
1697
+ if session_id and session_id not in session_ids:
1698
+ session_ids.append(session_id)
1699
+ return session_ids
1700
+
1701
+
1702
+ def worker_dispatch_records(
1703
+ team_state: Mapping[str, Any], worker_id: str | None = None
1704
+ ) -> list[Mapping[str, Any]]:
1705
+ """한 워커(또는 run 전체)의 `workerDispatches[]` 행을 기록된 순서대로.
1706
+
1707
+ 사용량 수집이 워커의 실행 증거를 찾는 열쇠는 이 행들이다 — 프롬프트 경로
1708
+ (status 사이드카 → 래퍼 창), 세션 id(claude 트랜스크립트), 워크트리 경로
1709
+ (grok·kimi 는 그 안에서 돌아 세션 디렉터리가 그 경로로 인코딩된다).
1710
+ `workers[]` 행의 `promptPath` 는 첫 dispatch 하나만 가리키므로 재검증·
1711
+ critic-gap·plan-verify 로 다시 띄운 세션은 거기서 보이지 않는다(실측
1712
+ 2026-09-08 jobs implementation-planning r01: codex 5회 dispatch 중 1회만
1713
+ 집계, v2 명부 행 `scope` 는 `promptPath` 가 비어 0회).
1714
+
1715
+ v1 행은 `workerId`, v2 행은 `assignmentRef` 마지막 마디로 워커에 묶인다
1716
+ (`_dispatch_worker_key`). 리스트가 아니면 빈 결과 — `worker_session_ids`
1717
+ 와 같은 이유로 호출자를 깨지 않는다.
1718
+ """
1695
1719
  dispatches = team_state.get("workerDispatches")
1720
+ records: list[Mapping[str, Any]] = []
1696
1721
  for record in dispatches if isinstance(dispatches, list) else []:
1697
1722
  if not isinstance(record, Mapping):
1698
1723
  continue
1699
1724
  if worker_id is not None and _dispatch_worker_key(record) != worker_id:
1700
1725
  continue
1701
- session_id = str(record.get("sessionId") or "").strip()
1702
- if session_id and session_id not in session_ids:
1703
- session_ids.append(session_id)
1704
- return session_ids
1726
+ records.append(record)
1727
+ return records
1705
1728
 
1706
1729
 
1707
1730
  def _dispatch_worker_key(record: Mapping[str, Any]) -> str:
@@ -281,12 +281,25 @@ def _validate_snapshot_authority(
281
281
  policies: tuple[WritePolicy, ...],
282
282
  ) -> None:
283
283
  expected = tuple(policy.digest for policy in policies)
284
- if (
285
- before.root != after.root
286
- or before.artifact_root != after.artifact_root
287
- or before.policy_digests != expected
288
- ):
289
- raise MutationAuditError("mutation snapshot does not match batch policies")
284
+ mismatches: list[str] = []
285
+ if before.root != after.root:
286
+ mismatches.append(f"root {before.root} != {after.root}")
287
+ if before.artifact_root != after.artifact_root:
288
+ mismatches.append(
289
+ f"artifactRoot {before.artifact_root} != {after.artifact_root}"
290
+ )
291
+ if before.policy_digests != expected:
292
+ mismatches.append(
293
+ "policyDigests snapshot=" + ",".join(before.policy_digests)
294
+ + " policies=" + ",".join(expected)
295
+ )
296
+ if mismatches:
297
+ # 어느 비교가 어긋났는지 없는 예외는 원인 특정이 불가능했다(실측
298
+ # 2026-09-08: 배치 안 재시도가 attempt 1 의 스냅샷을 attempt 2 의 계약과
299
+ # 대조해 죽었는데, 메시지는 "does not match batch policies" 뿐이었다).
300
+ raise MutationAuditError(
301
+ "mutation snapshot does not match batch policies: " + "; ".join(mismatches)
302
+ )
290
303
 
291
304
 
292
305
  def _maximum_precision(policy: WritePolicy) -> str:
@@ -33,6 +33,7 @@ from .ids import (
33
33
  )
34
34
  from .state import Prompt, WizardError, WizardState, _is_role_selection_step
35
35
  from .prompts import _domain_prompt
36
+ from .picker_navigation import present_picker, accept_picker_answer
36
37
  from .roles import _submit_role_prompt, next_role_prompt
37
38
  from .steps_identity import _submit_task_pick
38
39
  from .steps_analysis import _advance_design_prep_item
@@ -125,11 +126,24 @@ def next_prompt(state: WizardState) -> Prompt:
125
126
  for _ in range(len(STEPS)):
126
127
  prompt = _next_prompt_screen(state)
127
128
  if not _is_lone_free_input_pick(prompt):
128
- return prompt
129
+ return _native_picker_screen(state, prompt)
129
130
  _take_free_input_branch(state, prompt)
130
131
  return _next_prompt_screen(state)
131
132
 
132
133
 
134
+ def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
135
+ if "native_single_select" not in state.available_functions:
136
+ return prompt
137
+ if prompt.kind == "pick_group":
138
+ if _interaction_plan(state, prompt).kind == "native-group":
139
+ return prompt
140
+ prompt = prompt.questions[0]
141
+ if _interaction_plan(state, prompt).kind == "native-multi":
142
+ return prompt
143
+ limit = default_host_registry().resolve(state.host_runtime).interaction().native_option_limit
144
+ return present_picker(state, prompt, limit=limit)
145
+
146
+
133
147
  def _next_prompt_screen(state: WizardState) -> Prompt:
134
148
  if state.aborted:
135
149
  return Prompt(step=S_ABORTED, kind="aborted")
@@ -345,6 +359,13 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
345
359
  return {"echo": "", "next": prompt_payload(state, prompt)}
346
360
  plan = _interaction_plan(state, prompt)
347
361
  value = _normalize_interaction_answer(state, prompt, plan, value)
362
+ if plan.kind == "native-single":
363
+ original = _next_prompt_screen(state)
364
+ if original.kind == "pick_group":
365
+ original = next(q for q in original.questions if q.step == prompt.step)
366
+ value = accept_picker_answer(state, original, value)
367
+ if value is None:
368
+ return {"echo": "", "next": prompt_payload(state, next_prompt(state))}
348
369
  if prompt.kind == "pick_group":
349
370
  return _submit_group(state, prompt, value)
350
371
  if _is_role_selection_step(prompt.step):
@@ -0,0 +1,75 @@
1
+ """호스트 선택기 한도 안에서 원래 선택지를 보존하는 화면 변환."""
2
+ from dataclasses import replace
3
+
4
+ from okstra_ctl.wizard_stage_intent import WHOLE_TASK_STAGE
5
+
6
+ from .ids import ALL_STAGES, PICK_TYPE_CUSTOM
7
+ from .state import Option, Prompt, WizardError, WizardState
8
+
9
+ _PAGE_PREFIX = "__okstra_picker_page__:"
10
+ _DONE = "__okstra_picker_done__"
11
+
12
+
13
+ def present_picker(state: WizardState, prompt: Prompt, *, limit: int) -> Prompt:
14
+ if prompt.kind != "pick" or not prompt.options:
15
+ return prompt
16
+ selected = state.picker_selected.get(prompt.step, [])
17
+ labels = [option.label for option in prompt.options]
18
+ options = [replace(
19
+ option,
20
+ label=("✓ " if option.value in selected else "") + option.label
21
+ + (f" [{option.value}]" if labels.count(option.label) > 1 else ""),
22
+ ) 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)
26
+ offset = state.picker_offsets.get(prompt.step, 0)
27
+ offset = offset if 0 <= offset < len(options) else 0
28
+ shown = options[offset:offset + size]
29
+ label = prompt.label
30
+ if paged:
31
+ next_offset = offset + size if offset + size < len(options) else 0
32
+ shown.append(Option(
33
+ f"{_PAGE_PREFIX}{next_offset}",
34
+ "다음 선택지" if next_offset else "처음 선택지로",
35
+ ))
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
+ if len(shown) == 1:
41
+ shown.append(Option(f"{_PAGE_PREFIX}0", "다시 보기"))
42
+ shown = [o for o in shown if o.value != PICK_TYPE_CUSTOM] + [
43
+ o for o in shown if o.value == PICK_TYPE_CUSTOM
44
+ ]
45
+ return replace(prompt, label=label, options=shown, multi=False)
46
+
47
+
48
+ def accept_picker_answer(state: WizardState, prompt: Prompt, value: str) -> str | None:
49
+ if value.startswith(_PAGE_PREFIX):
50
+ raw_offset = value.removeprefix(_PAGE_PREFIX)
51
+ if not raw_offset.isdecimal() or not 0 <= int(raw_offset) < len(prompt.options):
52
+ raise WizardError("invalid picker page; select a returned navigation option")
53
+ state.picker_offsets[prompt.step] = int(raw_offset)
54
+ 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
@@ -42,6 +42,8 @@ class WizardState:
42
42
  host_runtime: str = "claude-code"
43
43
  host_entry_mode: str = "current-session"
44
44
  available_functions: list[str] = field(default_factory=list)
45
+ picker_offsets: dict[str, int] = field(default_factory=dict)
46
+ picker_selected: dict[str, list[str]] = field(default_factory=dict)
45
47
 
46
48
  # task identity
47
49
  is_new_task: Optional[bool] = None
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import os
6
+ from datetime import datetime, timezone
6
7
  from pathlib import Path
7
8
  from .jsonl_io import iter_jsonl
8
9
  from .paths import CODEX_SESSIONS, codex_session_roots, ts_in_window
@@ -50,6 +51,67 @@ def codex_session_total(jsonl_path: Path) -> dict:
50
51
  }
51
52
 
52
53
 
54
+ def codex_session_window_total(jsonl_path: Path, since: str, until: str) -> dict:
55
+ """창 안에서 이 세션이 쓴 토큰 — token_count 이벤트의 `last_token_usage` 합.
56
+
57
+ in-session 리드(`entryMode: current-session`)의 rollout 은 run 보다 먼저
58
+ 열려 다른 task 도 돌린 세션이라 마지막 `total_token_usage` 스냅샷은 세션
59
+ 전체다. 누적치는 이벤트마다 `last_token_usage` 만큼 늘고 컨텍스트 압축
60
+ 뒤 되돌아가므로(실측 2026-09-08 jobs 리드 세션: 601 이벤트 중 2회 감소),
61
+ 창 끝·시작 누적치의 차가 아니라 창 안 이벤트의 `last_token_usage` 를
62
+ 더한다. `last_token_usage` 가 없는 옛 기록은 직전 누적치와의 차(0 이상)로
63
+ 센다. 창 안 이벤트가 없으면 `available: False`.
64
+ """
65
+ keys = (
66
+ ("totalTokens", "total_tokens"),
67
+ ("inputTokens", "input_tokens"),
68
+ ("cachedInputTokens", "cached_input_tokens"),
69
+ ("outputTokens", "output_tokens"),
70
+ ("reasoningOutputTokens", "reasoning_output_tokens"),
71
+ )
72
+ sums = {name: 0 for name, _raw in keys}
73
+ previous: dict = {}
74
+ model_val: str | None = None
75
+ cwd_val: str | None = None
76
+ started: str | None = None
77
+ first: str | None = None
78
+ last: str | None = None
79
+ for rec in iter_jsonl(jsonl_path):
80
+ kind = rec.get("type")
81
+ payload = rec.get("payload") or {}
82
+ if kind == "session_meta":
83
+ cwd_val = payload.get("cwd")
84
+ started = payload.get("timestamp")
85
+ elif kind == "turn_context":
86
+ if model_val is None and payload.get("model"):
87
+ model_val = payload["model"]
88
+ elif kind == "event_msg" and payload.get("type") == "token_count":
89
+ info = payload.get("info") or {}
90
+ cumulative = info.get("total_token_usage") or {}
91
+ turn = info.get("last_token_usage")
92
+ timestamp = str(rec.get("timestamp") or "")
93
+ if timestamp and ts_in_window(timestamp, since, until):
94
+ for name, raw in keys:
95
+ if isinstance(turn, dict):
96
+ sums[name] += turn.get(raw, 0) or 0
97
+ else:
98
+ sums[name] += max(
99
+ 0, (cumulative.get(raw, 0) or 0) - (previous.get(raw, 0) or 0)
100
+ )
101
+ if first is None:
102
+ first = timestamp
103
+ last = timestamp
104
+ previous = cumulative
105
+ if first is None:
106
+ return {"totalTokens": 0, "cwd": cwd_val, "model": model_val, "available": False}
107
+ # 창 안에서 열린 세션은 세션 시작이 곧 창 안 활동의 시작이다. 창보다 먼저
108
+ # 열린 세션(in-session 리드)은 창 안 첫 이벤트부터 센다.
109
+ if started and ts_in_window(started, since, until):
110
+ first = started
111
+ return {**sums, "cwd": cwd_val, "model": model_val,
112
+ "startedAt": first, "endedAt": last, "available": True}
113
+
114
+
53
115
  def find_codex_session(cwd: Path, started_at: str, ended_at: str) -> Path | None:
54
116
  """Find the latest codex rollout jsonl in the requested window."""
55
117
  sessions = find_codex_sessions(cwd, started_at, ended_at)
@@ -104,14 +166,32 @@ def codex_session_ids(path: Path) -> set[str]:
104
166
  return {item for item in ids if item}
105
167
 
106
168
 
169
+ def _modified_in_window(path: Path, started_at: str, until: str) -> bool:
170
+ try:
171
+ mtime = path.stat().st_mtime
172
+ except OSError:
173
+ return False
174
+ modified = datetime.fromtimestamp(mtime, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
175
+ return ts_in_window(modified, started_at, None)
176
+
177
+
107
178
  def find_codex_sessions(
108
179
  cwd: Path,
109
180
  started_at: str,
110
181
  ended_at: str,
111
182
  *,
112
183
  session_roots: tuple[Path, ...] | None = None,
184
+ active_before_start: bool = False,
113
185
  ) -> list[Path]:
114
- """Find codex rollout jsonls whose meta.cwd matches the window."""
186
+ """Find codex rollout jsonls whose meta.cwd matches the window.
187
+
188
+ 기본은 창 안에서 *시작한* 세션이다 — exec 래퍼 워커는 dispatch 마다 새
189
+ rollout 을 열므로 그것으로 충분하다. `active_before_start=True` 는 창보다
190
+ 먼저 시작했지만 창 안에서도 기록이 이어진 세션(파일 mtime 이 창 시작 이후)을
191
+ 더한다. in-session 리드가 그 모양이다: 세션은 run 보다 먼저 태어났으니
192
+ 시작 시각으로 거르면 `no host session started in the run window` 로 빠지고,
193
+ 토큰은 `codex_session_window_total` 이 창으로 잘라 센다.
194
+ """
115
195
  if not started_at or not ended_at:
116
196
  return []
117
197
  if session_roots is None:
@@ -137,7 +217,12 @@ def find_codex_sessions(
137
217
  if session_cwd != target_cwd:
138
218
  continue
139
219
  if not ts_in_window(ts, started_at, ended_at):
140
- continue
220
+ if not (
221
+ active_before_start
222
+ and ts_in_window(ts, None, started_at)
223
+ and _modified_in_window(p, started_at, ended_at)
224
+ ):
225
+ continue
141
226
  candidates.append((ts, p))
142
227
  if not candidates:
143
228
  return []