okstra 0.200.1 → 0.201.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +4 -2
  2. package/dist/cli-registry.mjs +6 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/docs/cli.md +14 -3
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/report-writer-worker.md +7 -3
  8. package/runtime/bin/okstra-spawn-followups.py +2 -2
  9. package/runtime/prompts/duties/technical-verification-worker.md +44 -0
  10. package/runtime/prompts/launch.template.md +7 -1
  11. package/runtime/prompts/lead/okstra-lead-contract.md +7 -2
  12. package/runtime/prompts/lead/plan-body-verification.md +3 -1
  13. package/runtime/prompts/lead/report-writer.md +8 -2
  14. package/runtime/prompts/lead/team-contract.md +6 -0
  15. package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
  16. package/runtime/prompts/profiles/final-verification.md +5 -0
  17. package/runtime/prompts/profiles/forbidden-actions.json +6 -0
  18. package/runtime/prompts/profiles/implementation-option-selection.md +7 -1
  19. package/runtime/prompts/profiles/implementation-planning.md +1 -0
  20. package/runtime/prompts/profiles/technical-verification.md +53 -0
  21. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  22. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -4
  23. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +2 -0
  24. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +36 -5
  25. package/runtime/python/okstra_ctl/agent/invocation.py +14 -6
  26. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +4 -3
  27. package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +83 -22
  28. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +44 -2
  29. package/runtime/python/okstra_ctl/conformance.py +2 -20
  30. package/runtime/python/okstra_ctl/dispatch_core.py +25 -5
  31. package/runtime/python/okstra_ctl/dispatch_state.py +2 -0
  32. package/runtime/python/okstra_ctl/domain/provider.py +0 -1
  33. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  34. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -1
  35. package/runtime/python/okstra_ctl/implementation_direction.py +64 -7
  36. package/runtime/python/okstra_ctl/implementation_options.py +58 -45
  37. package/runtime/python/okstra_ctl/model_pool.py +2 -5
  38. package/runtime/python/okstra_ctl/next_phase.py +3 -0
  39. package/runtime/python/okstra_ctl/plan_items.py +15 -0
  40. package/runtime/python/okstra_ctl/plan_items_cli.py +9 -3
  41. package/runtime/python/okstra_ctl/qa_commands.py +30 -0
  42. package/runtime/python/okstra_ctl/registry/provider_registry.py +11 -8
  43. package/runtime/python/okstra_ctl/render.py +3 -0
  44. package/runtime/python/okstra_ctl/render_final_report.py +1 -0
  45. package/runtime/python/okstra_ctl/report_assembly.py +8 -2
  46. package/runtime/python/okstra_ctl/report_contract.py +3 -0
  47. package/runtime/python/okstra_ctl/report_corrections.py +209 -93
  48. package/runtime/python/okstra_ctl/report_finalize.py +25 -8
  49. package/runtime/python/okstra_ctl/report_html/router.py +2 -0
  50. package/runtime/python/okstra_ctl/report_html/view_models/technical_verification.py +21 -0
  51. package/runtime/python/okstra_ctl/report_projections.py +4 -3
  52. package/runtime/python/okstra_ctl/report_synthesis_packet.py +178 -26
  53. package/runtime/python/okstra_ctl/run.py +82 -0
  54. package/runtime/python/okstra_ctl/team.py +4 -1
  55. package/runtime/python/okstra_ctl/technical_verification.py +195 -0
  56. package/runtime/python/okstra_ctl/usage_identity.py +54 -0
  57. package/runtime/python/okstra_ctl/usage_report.py +22 -8
  58. package/runtime/python/okstra_ctl/verification_target.py +74 -0
  59. package/runtime/python/okstra_ctl/wizard/__init__.py +1 -1
  60. package/runtime/python/okstra_ctl/wizard/cli.py +2 -1
  61. package/runtime/python/okstra_ctl/wizard/confirmation.py +38 -2
  62. package/runtime/python/okstra_ctl/wizard/engine.py +3 -0
  63. package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
  64. package/runtime/python/okstra_ctl/wizard/outcome.py +63 -0
  65. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +2 -2
  66. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  67. package/runtime/python/okstra_ctl/wizard/render.py +8 -55
  68. package/runtime/python/okstra_ctl/wizard/roles.py +11 -7
  69. package/runtime/python/okstra_ctl/wizard/sources.py +28 -2
  70. package/runtime/python/okstra_ctl/wizard/state.py +13 -6
  71. package/runtime/python/okstra_ctl/wizard/steps_plan.py +8 -0
  72. package/runtime/python/okstra_ctl/worker_liveness.py +52 -39
  73. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  74. package/runtime/python/okstra_ctl/workflow.py +8 -0
  75. package/runtime/python/okstra_ctl/write_policy.py +23 -0
  76. package/runtime/python/okstra_token_usage/blocks.py +50 -1
  77. package/runtime/python/okstra_token_usage/claude.py +42 -21
  78. package/runtime/python/okstra_token_usage/codex.py +17 -0
  79. package/runtime/python/okstra_token_usage/collect.py +299 -162
  80. package/runtime/python/okstra_token_usage/cursor.py +2 -3
  81. package/runtime/python/okstra_token_usage/report.py +35 -30
  82. package/runtime/python/okstra_token_usage/task_totals.py +3 -12
  83. package/runtime/schemas/final-report-v2.0.schema.json +298 -7
  84. package/runtime/schemas/final-report-v3.0.schema.json +298 -7
  85. package/runtime/schemas/report-narrative-v3.0.schema.json +1 -0
  86. package/runtime/schemas/report-writer-corrections-v1.0.schema.json +30 -3
  87. package/runtime/skills/okstra-run/SKILL.md +10 -2
  88. package/runtime/skills/okstra-setup/SKILL.md +42 -7
  89. package/runtime/templates/report-writer-prompt-preamble.md +7 -3
  90. package/runtime/templates/reports/html/i18n/en.json +11 -0
  91. package/runtime/templates/reports/html/i18n/ko.json +11 -0
  92. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
  93. package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
  94. package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
  95. package/runtime/validators/validate-run.py +9 -4
@@ -147,6 +147,10 @@ class WizardState:
147
147
  # "" | "yes" | "no" — done(release-handoff) task 재진입의 fix-cycle 기록 여부
148
148
  fix_cycle: str = ""
149
149
  confirmed: Optional[bool] = None
150
+ confirmation_stages: str = ""
151
+ confirmation_prompt: str = ""
152
+ confirmation_scope: dict[str, Any] = field(default_factory=dict)
153
+ user_authorization: dict[str, Any] = field(default_factory=dict)
150
154
  edit_target: str = ""
151
155
  # terminal: user picked 중단 — no further prompt ever applies
152
156
  aborted: bool = False
@@ -225,26 +229,28 @@ class Prompt:
225
229
  self._check_recommendations()
226
230
 
227
231
  def _check_recommendations(self) -> None:
228
- """단일 선택의 추천은 정확히 하나이고 1번이다. 탈출구는 추천이 아니다.
232
+ """단일 추천은 하나다. 모델 선택은 제공자 순서, 그 외에는 추천이 앞이다.
229
233
 
230
234
  실측(2026-09-09, task 선택 화면): 남은 task 세 줄이 전부 `(추천)` 을
231
235
  달고 나왔고, 리드는 산문에서 2번을 권했다. 추천이 여럿이면 라벨은
232
236
  아무것도 고르지 않은 것이고, 추천이 1번이 아니면 사용자는 목록을
233
- 끝까지 읽어야 추천을 찾는다. 체크박스(`multi`)는 추천이 기본 선택
234
- 집합이라 여럿일 수 있되 앞머리에 모여 있다. 그리고 `직접 입력` /
237
+ 끝까지 읽어야 추천을 찾는다. 모델 선택은 제공자별 묶음을 우선한다.
238
+ 외 체크박스(`multi`)는 추천이 여럿일 수 있되 앞머리에 모인다.
239
+ 그리고 `직접 입력` /
235
240
  `중단` 은 앞의 선택지가 전부 맞지 않을 때의 탈출구이므로 추천 대상이
236
241
  될 수 없다.
237
242
  """
238
243
  flags = [option.recommended for option in self.options]
244
+ grouped_models = self.step.startswith(("role-models:", "role-model:"))
239
245
  if any(flags):
240
246
  if self.multi:
241
- if any(flags[index] for index in range(1, len(flags))
247
+ if not grouped_models and any(flags[index] for index in range(1, len(flags))
242
248
  if not flags[index - 1]):
243
249
  raise WizardError(
244
250
  f"wizard step {self.step!r}: recommended options must "
245
251
  "be the leading run of the list"
246
252
  )
247
- elif sum(flags) != 1 or not flags[0]:
253
+ elif sum(flags) != 1 or (not grouped_models and not flags[0]):
248
254
  raise WizardError(
249
255
  f"wizard step {self.step!r}: a single-select step carries "
250
256
  "exactly one recommendation and it is the first option"
@@ -684,7 +690,8 @@ _FIELD_DEFAULTS: dict[str, Any] = {
684
690
  "pr_template_path": "", "pr_template_pending_text": False,
685
691
  "pr_template_scope": "",
686
692
  "fix_cycle": "",
687
- "confirmed": None, "edit_target": "",
693
+ "confirmed": None, "edit_target": "", "confirmation_prompt": "", "confirmation_stages": "",
694
+ "confirmation_scope": {}, "user_authorization": {},
688
695
  }
689
696
 
690
697
 
@@ -12,6 +12,7 @@ from okstra_ctl.incremental_scope import CARRY_ALL_SCOPE
12
12
  from okstra_ctl.implementation_direction import (
13
13
  DirectionSelectionError,
14
14
  lexical_absolute_path,
15
+ resolve_selected_direction,
15
16
  validate_task_artifact_path,
16
17
  )
17
18
  from okstra_ctl.final_report_paths import final_report_data_path
@@ -191,6 +192,13 @@ def _submit_selected_direction_pick(
191
192
  candidates = _selected_direction_candidates(state)
192
193
  if value not in candidates:
193
194
  raise WizardError(t["errors"]["unknown"].format(value=value))
195
+ try:
196
+ resolve_selected_direction(
197
+ Path(state.project_root) / value,
198
+ expected_task_key=f"{state.project_id}:{state.task_group}:{state.task_id}",
199
+ )
200
+ except DirectionSelectionError as exc:
201
+ raise WizardError(str(exc)) from exc
194
202
  state.selected_direction_path = value
195
203
  # 두 입력은 상호 배타다 — 방향이 정해진 순간 이 런은 새 계획이고,
196
204
  # clarification 자리에 남은 값은 render-bundle 이 거절할 이유일 뿐이다.
@@ -162,7 +162,8 @@ def probe_launch(
162
162
  """Whether the wrapper behind *prompt* ever started."""
163
163
  log, status = _log_path(prompt), Path(f"{prompt}.status.json")
164
164
  probe = {"kind": "launch", "path": str(prompt)}
165
- if log.exists() or status.exists():
165
+ if any(path.is_file() and path.stat().st_mtime >= dispatched_at.timestamp()
166
+ for path in (log, status)):
166
167
  return {**probe, "state": "live", "reason": ""}
167
168
  waited = (now - dispatched_at).total_seconds()
168
169
  if waited <= grace:
@@ -273,7 +274,8 @@ def probe_all(
273
274
  def result_ready(target: ProbeTarget) -> bool:
274
275
  """Whether this worker's result file has landed with content in it."""
275
276
  path = target.result_path
276
- return bool(path and path.is_file() and path.stat().st_size > 0)
277
+ return bool(path and path.is_file() and path.stat().st_size > 0
278
+ and path.stat().st_mtime >= target.dispatched_at.timestamp())
277
279
 
278
280
 
279
281
  def wait_for_results(
@@ -370,53 +372,58 @@ def _dispatch_worker_id(record: Mapping[str, Any]) -> str:
370
372
  return ""
371
373
 
372
374
 
373
- def _dispatch_fallback(state: Mapping[str, Any], worker_id: str) -> dict:
374
- """같은 워커의 디스패치 마지막 것.
375
-
376
- 로스터 행에 없는 키를 여기서 보충한다. 재디스패치는 같은 워커의 행을 뒤에
377
- 덧붙이므로 마지막 행이 이번 시도다.
378
- """
379
- records = state.get("workerDispatches")
380
- if not isinstance(records, list):
381
- return {}
375
+ def _dispatch_fallback(state: Mapping[str, Any], worker_id: str, dispatch_id: str = "") -> dict:
376
+ """명시한 배정 또는 하나로 확정되는 구형 배정만 선택한다."""
377
+ records = state.get("workerDispatches") or []
382
378
  matches = [
383
- row for row in records
384
- if isinstance(row, Mapping) and _dispatch_worker_id(row) == worker_id
379
+ row for row in records if isinstance(row, Mapping)
380
+ and (row.get("dispatchId") == dispatch_id if dispatch_id
381
+ else _dispatch_worker_id(row) == worker_id)
385
382
  ]
386
- return dict(matches[-1]) if matches else {}
387
-
383
+ if len(matches) > 1:
384
+ raise DispatchError(f"multiple dispatches for {worker_id or dispatch_id}; pass --dispatch-id")
385
+ if dispatch_id and not matches:
386
+ raise DispatchError(f"team-state has no dispatchId={dispatch_id}")
387
+ return dict(matches[0]) if matches else {}
388
388
 
389
- def _worker_row(team_state_path: Path, worker_id: str) -> dict:
390
- """이 워커의 프로브 입력 — 로스터 행에 디스패치 행을 덧댄 것.
391
389
 
392
- `livenessMode` 디스패치 행에만 실린다(`dispatch_core._dispatch_record`);
393
- 로스터 행은 키를 갖지 않는다. 로스터만 읽으면 cmux 백엔드의 모든 run 에서
394
- 프로브가 전면 거부되고, 리드는 계약이 금지한 자체 폴링으로 밀려난다.
395
- 보충은 로스터에 없거나 빈 키에만 적용한다 — `startedAt` 처럼 로스터가 정본인
396
- 값을 디스패치 행이 덮어쓰면 grace 앵커가 이번 시도에서 어긋난다.
397
- """
390
+ def _worker_row(team_state_path: Path, worker_id: str, dispatch_id: str = "") -> dict:
391
+ """새 배정의 필드는 원자적으로 읽고 구형 단일 기록만 보완한다."""
398
392
  state = load_json_object(team_state_path, "team-state")
393
+ dispatch = _dispatch_fallback(state, worker_id, dispatch_id)
394
+ if dispatch.get("dispatchId") and dispatch.get("startedAt"):
395
+ result = dispatch.get("resultPath")
396
+ root = _project_root_for_team_state(team_state_path)
397
+ if result and any(
398
+ isinstance(row, Mapping) and row.get("dispatchId") != dispatch["dispatchId"]
399
+ and row.get("status") not in {"completed", "error", "timeout", "not-run"}
400
+ and row.get("resultPath")
401
+ and (root / row["resultPath"]).resolve() == (root / result).resolve()
402
+ for row in state.get("workerDispatches", [])
403
+ ):
404
+ raise DispatchError("resultPath is shared by dispatches; use an attempt-specific result path")
405
+ return dispatch
399
406
  workers = state.get("workers")
400
407
  if not isinstance(workers, list):
401
408
  raise DispatchError(f"team-state workers must be an array: {team_state_path}")
402
- worker = next(
403
- (
404
- row for row in workers
405
- if isinstance(row, dict) and row.get("workerId") == worker_id
406
- ),
407
- None,
408
- )
409
+ selected_worker = _dispatch_worker_id(dispatch) if dispatch_id else worker_id
410
+ worker = next((row for row in workers if isinstance(row, dict)
411
+ and row.get("workerId") == selected_worker), None)
409
412
  if worker is None:
410
- raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
413
+ raise DispatchError(f"team-state has no workerId={selected_worker}: {team_state_path}")
414
+ if dispatch and worker.get("promptPath") and dispatch.get("promptPath"):
415
+ root = _project_root_for_team_state(team_state_path)
416
+ if (root / worker["promptPath"]).resolve() != (root / dispatch["promptPath"]).resolve():
417
+ raise DispatchError("dispatch has no startedAt and roster promptPath differs; repair the recorded attempt")
411
418
  merged = dict(worker)
412
- for key, value in _dispatch_fallback(state, worker_id).items():
419
+ for key, value in dispatch.items():
413
420
  current = merged.get(key)
414
421
  if key not in merged or (isinstance(current, str) and not current.strip()):
415
422
  merged[key] = value
416
423
  return merged
417
424
 
418
425
 
419
- def probe_target(team_state_value: str, worker_id: str) -> ProbeTarget:
426
+ def probe_target(team_state_value: str, worker_id: str, *, dispatch_id: str = "") -> ProbeTarget:
420
427
  """Resolve one worker's probe target from team-state.
421
428
 
422
429
  ``livenessMode`` is authoritative — never infer the transport from the
@@ -424,7 +431,7 @@ def probe_target(team_state_value: str, worker_id: str) -> ProbeTarget:
424
431
  worker for a wrapper log that will never exist.
425
432
  """
426
433
  team_state_path = Path(team_state_value).resolve()
427
- worker = _worker_row(team_state_path, worker_id)
434
+ worker = _worker_row(team_state_path, worker_id, dispatch_id)
428
435
  mode = worker.get("livenessMode")
429
436
  field = _ARTIFACT_FIELD_BY_MODE.get(mode) if isinstance(mode, str) else None
430
437
  if field is None:
@@ -492,6 +499,8 @@ def main(argv: list[str] | None = None) -> int:
492
499
  help="team-state path for a pending worker (repeatable)")
493
500
  parser.add_argument("--worker", action="append", default=[],
494
501
  help="worker id paired with --team-state (repeatable)")
502
+ parser.add_argument("--dispatch-id", action="append", default=[],
503
+ help="exact dispatch id paired with --team-state; do not mix with --worker")
495
504
  parser.add_argument("--max-idle", type=float, default=HEARTBEAT_MAX_GAP_SECONDS,
496
505
  help="heartbeat staleness budget in seconds")
497
506
  parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
@@ -520,15 +529,19 @@ def main(argv: list[str] | None = None) -> int:
520
529
  )
521
530
  args = parser.parse_args(argv)
522
531
 
523
- if len(args.team_state) != len(args.worker):
524
- parser.error("each --team-state must have one paired --worker")
532
+ if args.worker and args.dispatch_id:
533
+ parser.error("use --worker or --dispatch-id, not both")
534
+ selectors = args.dispatch_id or args.worker
535
+ if len(args.team_state) != len(selectors):
536
+ parser.error("each --team-state must have one paired --worker or --dispatch-id")
525
537
  if not args.team_state:
526
538
  parser.error("pass at least one --team-state/--worker pair")
527
539
 
528
540
  try:
529
541
  targets = [
530
- probe_target(team_state, worker)
531
- for team_state, worker in zip(args.team_state, args.worker, strict=True)
542
+ probe_target(team_state, "" if args.dispatch_id else worker,
543
+ dispatch_id=worker if args.dispatch_id else "")
544
+ for team_state, worker in zip(args.team_state, selectors, strict=True)
532
545
  ]
533
546
  except DispatchError as exc:
534
547
  parser.error(str(exc))
@@ -547,7 +560,7 @@ def main(argv: list[str] | None = None) -> int:
547
560
  return 0 if result["ok"] else 1
548
561
 
549
562
  unwaitable = [
550
- worker for target, worker in zip(targets, args.worker, strict=True)
563
+ worker for target, worker in zip(targets, selectors, strict=True)
551
564
  if target.result_path is None
552
565
  ]
553
566
  if unwaitable:
@@ -49,6 +49,7 @@ FINAL_VERIFICATION_HEADERS = (
49
49
  "**Verification target digest:**",
50
50
  )
51
51
  SUPPORTED_TASK_TYPES = frozenset({
52
+ "technical-verification",
52
53
  "requirements-discovery",
53
54
  "error-analysis",
54
55
  "implementation-option-selection",
@@ -65,6 +66,7 @@ SUPPORTED_TASK_TYPES = frozenset({
65
66
  # something else gets its own. A task type absent from this map takes the
66
67
  # observational default below: describe the area, do not design for it.
67
68
  ANALYSIS_DUTY_BY_TASK_TYPE: dict[str, AgentAudience] = {
69
+ "technical-verification": "technical-verification-worker",
68
70
  "requirements-discovery": "discovery-worker",
69
71
  "improvement-discovery": "discovery-worker",
70
72
  "error-analysis": "diagnosis-worker",
@@ -39,6 +39,14 @@ ERROR_ANALYSIS_ROUTING_DIRECTIONS = {
39
39
  # prompt template 에 그대로 박혀 lead 가 읽는다. forbidden actions 는 이 dict 가
40
40
  # 아니라 prompts/profiles/forbidden-actions.json (load_phase_forbidden) 이 SSOT.
41
41
  PHASE_RULES: dict[str, dict[str, str]] = {
42
+ "technical-verification": {
43
+ "allowed": (
44
+ " - falsifiable experiment plans for the frozen unresolved facts\n"
45
+ " - dependency installation, source experiments, tests and builds only in this run's experiment copies\n"
46
+ " - command logs, observed signals and per-fact supported/refuted/inconclusive/not-run results\n"
47
+ " - return to implementation-option-selection with evidence, without adoption or plan approval"
48
+ ),
49
+ },
42
50
  "requirements-discovery": {
43
51
  "allowed": (
44
52
  " - work-category classification (bugfix / feature / refactor / ops / improvement)\n"
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import hashlib
5
5
  import json
6
+ import re
6
7
  import subprocess
7
8
  from collections.abc import Mapping, Sequence
8
9
  from dataclasses import dataclass
@@ -120,6 +121,27 @@ def _role_qa_artifact_paths(role: str, task_root: Path | None) -> tuple[Path, ..
120
121
  return ()
121
122
 
122
123
 
124
+
125
+ def _technical_experiment_paths(
126
+ artifact_paths: Sequence[Path], task_root: Path | None,
127
+ ) -> tuple[Path, ...]:
128
+ """정규 작업 결과 경로에서 해당 작업자의 시험 디렉터리만 파생한다."""
129
+ if task_root is None:
130
+ return ()
131
+ run_root = task_root / "runs" / "technical-verification"
132
+ paths = set()
133
+ for artifact in artifact_paths:
134
+ if artifact.parent != run_root / "worker-results":
135
+ continue
136
+ match = re.fullmatch(
137
+ r"([a-z0-9][a-z0-9-]*)-worker-technical-verification-(\d{3,})\.md",
138
+ artifact.name,
139
+ )
140
+ if match:
141
+ paths.add(run_root / "experiments" / match[2] / match[1])
142
+ return tuple(sorted(paths))
143
+
144
+
123
145
  def build_invocation_write_contract(
124
146
  *,
125
147
  role: str,
@@ -142,6 +164,7 @@ def build_invocation_write_contract(
142
164
  _relative_to_root(path, root, "artifact")
143
165
  for path in (
144
166
  *artifact_paths,
167
+ *_technical_experiment_paths(artifact_paths, task_root),
145
168
  *_role_qa_artifact_paths(role, task_root),
146
169
  )
147
170
  )
@@ -1,12 +1,62 @@
1
1
  """Per-source usage block constructors (consumed by collect())."""
2
2
  from __future__ import annotations
3
3
 
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
4
7
  from .paths import utc_now
5
8
  from .pricing import (
6
9
  claude_billable_equivalent,
7
10
  claude_cost_usd,
8
11
  )
9
12
 
13
+ _CACHE_INCLUDED_SOURCES = frozenset({"codex-cli", "grok-cli", "kimi-cli"})
14
+
15
+
16
+ def accounting_workers(state: Mapping[str, Any]) -> list[dict]:
17
+ """초기 명부와 명부 밖 실행의 사용량 행을 같은 소비 경로로 전달한다."""
18
+ rows = []
19
+ for key in ("workers", "additionalWorkerUsage"):
20
+ collection = state.get(key)
21
+ if isinstance(collection, list):
22
+ rows.extend(row for row in collection if isinstance(row, dict))
23
+ return rows
24
+
25
+
26
+ def usage_blocks(state: dict) -> list[dict]:
27
+ blocks = [state.get("leadUsage") or {}]
28
+ blocks.extend(worker.get("usage") or {} for worker in accounting_workers(state))
29
+ unattributed = (state.get("usageSummary") or {}).get("unattributedWorkerUsage")
30
+ if isinstance(unattributed, dict):
31
+ blocks.append(unattributed)
32
+ return [block for block in blocks if isinstance(block, dict)]
33
+
34
+
35
+ def normalize_usage_block(block: dict) -> dict:
36
+ """과거 캐시 포함 합계를 정규화하고 게시 당시 합계를 별도로 보존한다."""
37
+ normalized = dict(block)
38
+ cached = block.get("cachedInputTokens") or 0
39
+ if (
40
+ block.get("source") not in _CACHE_INCLUDED_SOURCES
41
+ or "cacheReadTokens" in block
42
+ or block.get("accountingBasis") == "cache-read-excluded"
43
+ or not isinstance(cached, int)
44
+ or isinstance(cached, bool)
45
+ or cached <= 0
46
+ ):
47
+ return normalized
48
+ total = block.get("totalTokens")
49
+ if not isinstance(total, int) or total < cached:
50
+ return normalized
51
+ normalized["reportedTotalTokens"] = total
52
+ normalized["totalTokens"] = total - cached
53
+ normalized["cacheReadTokens"] = cached
54
+ normalized["accountingBasis"] = "cache-read-excluded"
55
+ if isinstance(block.get("cliTotalTokens"), int):
56
+ normalized["reportedCliTotalTokens"] = block["cliTotalTokens"]
57
+ normalized["cliTotalTokens"] = max(0, block["cliTotalTokens"] - cached)
58
+ return normalized
59
+
10
60
 
11
61
  def usage_block(totals: dict, source: str, note: str | None = None) -> dict:
12
62
  block = {
@@ -65,4 +115,3 @@ def na_block(reason: str) -> dict:
65
115
  "collectedAt": utc_now(),
66
116
  "note": reason,
67
117
  }
68
-
@@ -4,12 +4,12 @@ from __future__ import annotations
4
4
  import json
5
5
  import sys
6
6
  import re
7
- from datetime import datetime
8
7
  from pathlib import Path
9
8
 
10
9
  from .cursor import MAX_NEEDLES, fresh_cache, load_cache, save_cache
11
10
  from .paths import claude_project_dir, ts_in_window
12
11
  from okstra_ctl.wrapper_status import read_wrapper_status
12
+ from okstra_ctl.usage_cells import duration_ms_from_bounds
13
13
 
14
14
  # lead 의 단계 체크포인트 라인(prompts/lead/okstra-lead-contract.md "Progress reporting") — phase id 가
15
15
  # `phase-<digit>` 로 시작하는 라인만 마커로 인정해 일반 대화의 오탐을 줄인다.
@@ -45,19 +45,13 @@ def claude_code_status_total(status_path: Path) -> dict:
45
45
  }
46
46
 
47
47
 
48
- def _event_from_record(rec: dict) -> dict | None:
49
- """jsonl 레코드 1개 압축 이벤트. 집계에 기여하지 않으면 None.
50
-
51
- 키: t=timestamp, i/o=input/output, c=cache_creation 합, c5/c1=ephemeral
52
- 5m/1h, r=cache_read, u=tool_use 수. 0/부재 필드는 생략(캐시 크기 절약).
53
- ts-only 레코드도 보존한다 — 임의 윈도우의 first/last ts 산출에 필요.
54
- """
55
- msg = rec.get("message")
56
- if not isinstance(msg, dict):
57
- msg = {}
48
+ def _usage_from_message(msg: dict) -> dict:
49
+ """응답 식별자를 남겨 여러 출력 조각의 사용량을 번만 집계한다."""
58
50
  ev: dict = {}
59
51
  usage = msg.get("usage")
60
- if usage:
52
+ if isinstance(usage, dict):
53
+ if isinstance(msg.get("id"), str) and msg["id"]:
54
+ ev["m"] = msg["id"]
61
55
  for src, key in (("input_tokens", "i"), ("output_tokens", "o"),
62
56
  ("cache_read_input_tokens", "r")):
63
57
  v = usage.get(src, 0) or 0
@@ -79,6 +73,15 @@ def _event_from_record(rec: dict) -> dict | None:
79
73
  elif cc_total:
80
74
  # API 분해가 없으면 전부 5m 티어로(1.25x — 더 싼 가정, 기존 동작).
81
75
  ev["c5"] = cc_total
76
+ return ev
77
+
78
+
79
+ def _event_from_record(rec: dict) -> dict | None:
80
+ """사용량과 별개로 각 조각의 도구·진행·시각 이벤트를 보존한다."""
81
+ msg = rec.get("message")
82
+ if not isinstance(msg, dict):
83
+ msg = {}
84
+ ev = _usage_from_message(msg)
82
85
  if rec.get("type") == "assistant":
83
86
  tools = sum(1 for b in (msg.get("content") or [])
84
87
  if isinstance(b, dict) and b.get("type") == "tool_use")
@@ -102,6 +105,31 @@ def _event_from_record(rec: dict) -> dict | None:
102
105
  return ev or None
103
106
 
104
107
 
108
+ def _response_usage_events(events: list[dict], since: str | None, until: str | None):
109
+ """응답별 누적 스냅샷의 창 안 증가량만 세며 도구 이벤트는 그대로 둔다."""
110
+ latest: dict[str, int] = {}
111
+ baseline: dict[str, dict] = {}
112
+ for index, event in enumerate(events):
113
+ message_id = event.get("m")
114
+ timestamp = event.get("t")
115
+ if message_id and (not timestamp or ts_in_window(timestamp, None, until)):
116
+ latest[message_id] = index
117
+ if timestamp and since and not ts_in_window(timestamp, since, None):
118
+ baseline[message_id] = event
119
+ usage_keys = {"i", "o", "c", "c5", "c1", "r"}
120
+ for index, event in enumerate(events):
121
+ message_id = event.get("m")
122
+ if not message_id:
123
+ yield event
124
+ continue
125
+ projected = {key: value for key, value in event.items() if key not in usage_keys}
126
+ if latest.get(message_id) == index:
127
+ previous = baseline.get(message_id) or {}
128
+ projected.update({key: max(0, event.get(key, 0) - previous.get(key, 0))
129
+ for key in usage_keys})
130
+ yield projected
131
+
132
+
105
133
  def _session_meta_from_record(rec: dict) -> tuple[str | None, str | None]:
106
134
  """레코드에서 (agentName, model) 후보 추출 — 둘 다 first-non-null 정책."""
107
135
  agent = rec.get("agentName") or None
@@ -213,7 +241,7 @@ def _totals_from_events(events: list[dict], agent_name: str | None,
213
241
  progress_markers: list[dict] = []
214
242
  first_ts: str | None = None
215
243
  last_ts: str | None = None
216
- for ev in events:
244
+ for ev in _response_usage_events(events, since, until):
217
245
  ts = ev.get("t")
218
246
  if ts and not ts_in_window(ts, since, until):
219
247
  continue
@@ -231,14 +259,7 @@ def _totals_from_events(events: list[dict], agent_name: str | None,
231
259
  first_ts = ts
232
260
  if last_ts is None or ts > last_ts:
233
261
  last_ts = ts
234
- duration_ms = 0
235
- if first_ts and last_ts:
236
- try:
237
- a = datetime.fromisoformat(first_ts.replace("Z", "+00:00"))
238
- b = datetime.fromisoformat(last_ts.replace("Z", "+00:00"))
239
- duration_ms = max(0, int((b - a).total_seconds() * 1000))
240
- except ValueError:
241
- duration_ms = 0
262
+ duration_ms = duration_ms_from_bounds(first_ts, last_ts) or 0
242
263
  # '처리 토큰' total 에서 cache_read 는 제외한다. claude 는 매 턴 직전까지의
243
264
  # 컨텍스트 전체를 캐시에서 재읽기(cache_read)하므로, 단순 합산하면 같은 토큰을
244
265
  # 턴 수만큼 중복 카운트해 처리량이 비현실적으로 부풀려진다(예: in-session
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import os
6
+ import re
6
7
  from datetime import datetime, timezone
7
8
  from pathlib import Path
8
9
  from .jsonl_io import iter_jsonl
@@ -12,6 +13,22 @@ from .paths import CODEX_SESSIONS, codex_session_roots, ts_in_window
12
13
  _DEFAULT_CODEX_SESSIONS = CODEX_SESSIONS
13
14
 
14
15
 
16
+ def codex_wrapper_session_ids(log_path: Path) -> set[str]:
17
+ """사용자 프롬프트 앞 실행기 헤더에서만 세션 ID를 읽는다."""
18
+ try:
19
+ with log_path.open(encoding="utf-8", errors="replace") as stream:
20
+ for line in stream:
21
+ clean = re.sub(r"\x1b\[[0-9;]*m", "", line).strip()
22
+ if clean == "user" or clean.endswith("[worker]user"):
23
+ break
24
+ match = re.search(r"(?:^|\[worker\])session id:\s*([\w-]+)\s*$", clean)
25
+ if match:
26
+ return {match.group(1)}
27
+ except OSError:
28
+ return set()
29
+ return set()
30
+
31
+
15
32
  def codex_session_total(jsonl_path: Path) -> dict:
16
33
  """Return last token_count snapshot from a codex rollout jsonl."""
17
34
  last: dict | None = None