okstra 0.200.0 → 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 (96) 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 +11 -5
  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 +181 -47
  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-synthesis-packet-v1.0.schema.json +1 -1
  87. package/runtime/schemas/report-writer-corrections-v1.0.schema.json +30 -3
  88. package/runtime/skills/okstra-run/SKILL.md +10 -2
  89. package/runtime/skills/okstra-setup/SKILL.md +42 -7
  90. package/runtime/templates/report-writer-prompt-preamble.md +7 -3
  91. package/runtime/templates/reports/html/i18n/en.json +11 -0
  92. package/runtime/templates/reports/html/i18n/ko.json +11 -0
  93. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
  94. package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
  95. package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
  96. package/runtime/validators/validate-run.py +9 -4
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  from typing import Any
10
10
  from okstra_project.dirs import OKSTRA_RELATIVE
11
11
 
12
- from .blocks import na_block, usage_block
12
+ from .blocks import accounting_workers, na_block, usage_block
13
13
  from .claude import (
14
14
  claude_code_status_total,
15
15
  claude_session_totals,
@@ -21,6 +21,7 @@ from .codex import (
21
21
  codex_session_is_worker,
22
22
  codex_session_total,
23
23
  codex_session_window_total,
24
+ codex_wrapper_session_ids,
24
25
  find_codex_sessions,
25
26
  )
26
27
  from .antigravity import (
@@ -37,7 +38,11 @@ from .grok import (
37
38
  )
38
39
  from .paths import claude_project_dir, find_session_jsonl, utc_now
39
40
  from .pricing import antigravity_cost_usd, provider_billable_equivalent, provider_cost_usd
40
- from okstra_ctl.dispatch_state import worker_dispatch_records, worker_session_ids
41
+ from okstra_ctl.usage_identity import (
42
+ unrostered_usage_workers,
43
+ usage_dispatch_records,
44
+ usage_session_ids,
45
+ )
41
46
  from okstra_ctl.models import provider_wrappers
42
47
  from okstra_ctl.wrapper_status import (
43
48
  log_path_for_prompt,
@@ -248,7 +253,7 @@ def _recorded_run_end(state: dict, manifest: dict | None) -> str | None:
248
253
  return None
249
254
  ends: list[str] = []
250
255
  blocks = [state.get("leadUsage")] + [
251
- worker.get("usage") for worker in (state.get("workers") or []) if isinstance(worker, dict)
256
+ worker.get("usage") for worker in accounting_workers(state)
252
257
  ]
253
258
  for block in blocks:
254
259
  if not isinstance(block, dict) or block.get("source") == "unavailable":
@@ -586,7 +591,7 @@ def _codex_worker_windows(project_root: Path, state: dict) -> dict[str, list[tup
586
591
  windows.setdefault(worker_id, []).append((started_at, timestamp))
587
592
  running_workers = {
588
593
  str(worker.get("workerId") or "").strip()
589
- for worker in state.get("workers", [])
594
+ for worker in accounting_workers(state)
590
595
  if isinstance(worker, dict) and worker.get("status") in {"running", "in-progress"}
591
596
  }
592
597
  if running_workers:
@@ -663,7 +668,7 @@ def _cli_session_totals(
663
668
  )
664
669
  elif provider == "grok":
665
670
  total = grok_session_total(session_path)
666
- elif provider == "zai":
671
+ elif provider in {"zai", "claude"}:
667
672
  total = claude_code_status_total(session_path)
668
673
  else:
669
674
  continue
@@ -919,11 +924,9 @@ def _worker_cli_usage_block(
919
924
  used_fallback = not windows
920
925
  if used_fallback:
921
926
  windows = fallback_windows
922
- session_paths: list[Path] = []
923
- for cwd in _worker_session_cwds(project_root, records):
924
- for path in _cli_sessions_for_windows(provider, cwd, windows):
925
- if path not in session_paths:
926
- session_paths.append(path)
927
+ session_paths, attribution = _worker_cli_sessions(
928
+ provider, project_root, worker, records, windows, status_paths,
929
+ )
927
930
  status_path = status_paths[-1] if status_paths else None
928
931
  if provider == "zai":
929
932
  session_paths = list(dict.fromkeys(
@@ -942,6 +945,7 @@ def _worker_cli_usage_block(
942
945
  sessions=session_paths,
943
946
  fallback_window_used=used_fallback,
944
947
  )
948
+ block.update(attribution)
945
949
  durations = [
946
950
  wrapper_execution(path).get("durationMs")
947
951
  for path in status_paths
@@ -953,6 +957,97 @@ def _worker_cli_usage_block(
953
957
  return block
954
958
 
955
959
 
960
+ def _worker_cli_sessions(
961
+ provider: str, project_root: Path, worker: dict, records: list[dict],
962
+ windows: list[tuple[str, str]], status_paths: list[Path | None],
963
+ ) -> tuple[list[Path], dict]:
964
+ candidates = list(dict.fromkeys(
965
+ path for cwd in _worker_session_cwds(project_root, records)
966
+ for path in _cli_sessions_for_windows(provider, cwd, windows)
967
+ ))
968
+ if provider != "codex":
969
+ return candidates, {}
970
+ wanted = {str(record["sessionId"]) for record in records if record.get("sessionId")}
971
+ for raw in _worker_prompt_paths(worker, records):
972
+ prompt = _resolve_project_path(project_root, raw)
973
+ if prompt is not None:
974
+ wanted.update(codex_wrapper_session_ids(log_path_for_prompt(prompt)))
975
+ for path in status_paths:
976
+ status = read_wrapper_status(path) if path is not None else None
977
+ if status is not None and status.raw.get("sessionId"):
978
+ wanted.add(str(status.raw["sessionId"]))
979
+ if wanted:
980
+ selected = [path for path in candidates if codex_session_ids(path) & wanted]
981
+ found = {session_id for path in selected for session_id in codex_session_ids(path)}
982
+ return selected, {"attribution": "session-id", "requestedSessionIds": sorted(wanted),
983
+ "missingSessionIds": sorted(wanted - found)}
984
+ unique_windows = all(
985
+ len({path for cwd in _worker_session_cwds(project_root, records)
986
+ for path in _cli_sessions_for_windows(provider, cwd, [window])}) <= 1
987
+ for window in windows
988
+ )
989
+ if unique_windows:
990
+ return candidates, {"attribution": "legacy-window"}
991
+ return [], {
992
+ "attribution": "ambiguous", "unattributedCliSessionPaths": [str(path) for path in candidates],
993
+ "cliNote": "Several Codex sessions match the wrapper window; no session identity was recorded.",
994
+ }
995
+
996
+
997
+ def _remove_shared_codex_usage(state: dict) -> None:
998
+ """같은 세션의 시간대 추정을 확정된 소유자보다 우선하지 않는다."""
999
+ rows = [row for row in accounting_workers(state)
1000
+ if (row.get("usage") or {}).get("source") == "codex-cli"]
1001
+ owners: dict[str, list[dict]] = {}
1002
+ for row in rows:
1003
+ for path in row["usage"].get("cliSessionPaths") or []:
1004
+ owners.setdefault(path, []).append(row)
1005
+ exclusions = []
1006
+ for row in rows:
1007
+ block = row["usage"]
1008
+ rejected = []
1009
+ for path in block.get("cliSessionPaths") or []:
1010
+ shared = owners[path]
1011
+ if len(shared) < 2:
1012
+ continue
1013
+ exact = [item for item in shared if item["usage"].get("attribution") == "session-id"]
1014
+ if len(exact) != 1 or exact[0] is not row:
1015
+ rejected.append(path)
1016
+ if not rejected:
1017
+ continue
1018
+ exclusions.append((row, rejected))
1019
+ for row, rejected in exclusions:
1020
+ block = row["usage"]
1021
+ kept = [Path(path) for path in block.get("cliSessionPaths") or [] if path not in rejected]
1022
+ replacement = collect_cli_usage(provider="codex", status_path=None, sessions=kept)
1023
+ replacement.update({key: block[key] for key in ("durationMs", "cliExecutionStatus") if key in block})
1024
+ replacement.update(attribution="ambiguous", unattributedCliSessionPaths=rejected,
1025
+ cliNote="Codex session matched more than one worker; duplicate attribution excluded.")
1026
+ row["usage"] = replacement
1027
+
1028
+
1029
+ def _unattributed_usage(state: dict, claude_usage: dict | None) -> dict | None:
1030
+ workers = accounting_workers(state)
1031
+ assigned = {path for row in workers for path in (row.get("usage") or {}).get("cliSessionPaths") or []}
1032
+ assigned.update((state.get("leadUsage") or {}).get("cliSessionPaths") or [])
1033
+ ambiguous = {path for row in workers
1034
+ for path in (row.get("usage") or {}).get("unattributedCliSessionPaths") or []}
1035
+ paths = [Path(path) for path in sorted(ambiguous - assigned)]
1036
+ totals = _cli_session_totals("codex", paths)
1037
+ if not totals:
1038
+ return claude_usage
1039
+ cli_usage = _cli_usage_block("codex", _aggregate_totals(totals), paths)
1040
+ blocks = [cli_usage] + ([claude_usage] if claude_usage is not None else [])
1041
+ combined = _aggregate_totals(blocks)
1042
+ for key in ("billableEquivalentTokens", "estimatedCostUsd", "cliEstimatedCostUsd"):
1043
+ combined[key] = sum(block.get(key, 0) or 0 for block in blocks)
1044
+ combined.update(source="unattributed", cliSessionPaths=[str(path) for path in paths],
1045
+ note="Measured session usage counted once; worker identity is ambiguous.")
1046
+ if claude_usage is not None:
1047
+ combined["sessionIds"] = claude_usage.get("sessionIds") or []
1048
+ return combined
1049
+
1050
+
956
1051
  def _wrapper_claude_worker(worker: dict) -> bool:
957
1052
  """호스트가 claude 가 아닐 때 래퍼로 띄운 claude 워커."""
958
1053
  provider = str(worker.get("provider") or worker.get("agent") or "").strip()
@@ -979,10 +1074,11 @@ def _claude_wrapper_usage_block(
979
1074
  호스트의 `collect_claude_runtime_usage` 가 같은 id 로 하는 일을 여기서 한다.
980
1075
  """
981
1076
  since, until = window
982
- session_ids = worker_session_ids(state, worker_id)
1077
+ session_ids = usage_session_ids(state, worker_id)
983
1078
  if not session_ids:
984
- return na_block(
985
- "claude wrapper worker has no dispatch session id recorded in workerDispatches"
1079
+ return _claude_wrapper_status_usage(
1080
+ project_root, usage_dispatch_records(state, worker_id),
1081
+ "claude wrapper worker has no dispatch session id recorded in workerDispatches",
986
1082
  )
987
1083
  totals: list[dict] = []
988
1084
  paths: list[Path] = []
@@ -1002,7 +1098,8 @@ def _claude_wrapper_usage_block(
1002
1098
  paths.append(path)
1003
1099
  attributed.append(session_id)
1004
1100
  if not totals:
1005
- return na_block(
1101
+ return _claude_wrapper_status_usage(
1102
+ project_root, usage_dispatch_records(state, worker_id),
1006
1103
  "claude session jsonl not found under "
1007
1104
  f"{claude_project_dir(project_root)} for dispatch session ids {session_ids}"
1008
1105
  )
@@ -1012,30 +1109,23 @@ def _claude_wrapper_usage_block(
1012
1109
  return block
1013
1110
 
1014
1111
 
1015
- def _attach_cli_usage(
1016
- block: dict,
1017
- provider: str,
1018
- project_root: Path,
1019
- windows: list[tuple[str, str]],
1020
- status_path: Path | None,
1021
- fallback_window_used: bool,
1022
- ) -> None:
1023
- """Layer aggregated CLI token/cost onto a Claude-side worker ``block``.
1024
-
1025
- A CLI-wrapper worker re-dispatched within one aggregated wrapper
1026
- window produces several rollout jsonls; pricing only the latest (the old
1027
- ``find_codex_session`` -> ``sessions[-1]`` behavior) dropped the earlier
1028
- attempts' tokens. We sum every in-window session here so the redispatched
1029
- CLI spend is fully reported. antigravity 는 status 사이드카의 ``usage``
1030
- 스냅샷을 같은 합산 경로에 붙인다.
1031
- """
1032
- session_paths = _cli_sessions_for_windows(provider, project_root, windows)
1033
- cli = collect_cli_usage(
1034
- provider=provider,
1035
- status_path=status_path,
1036
- sessions=session_paths,
1037
- fallback_window_used=fallback_window_used,
1038
- )
1112
+ def _claude_wrapper_status_usage(project_root: Path, records: list, reason: str) -> dict:
1113
+ paths = []
1114
+ for raw in _worker_prompt_paths({}, records):
1115
+ prompt = _resolve_project_path(project_root, raw)
1116
+ if prompt is not None:
1117
+ paths.append(status_path_for_prompt(prompt))
1118
+ totals = _cli_session_totals("claude", paths)
1119
+ if not totals:
1120
+ return na_block(reason)
1121
+ block = usage_block(_aggregate_totals(totals), source="claude-jsonl")
1122
+ block["attribution"] = "wrapper-status"
1123
+ block["statusPaths"] = [str(path) for path in paths]
1124
+ return block
1125
+
1126
+
1127
+ def _attach_cli_usage(block: dict, cli: dict) -> None:
1128
+ """Claude 호스트 사용량에 이미 귀속한 공급자 CLI 비용을 붙인다."""
1039
1129
  for key in (
1040
1130
  "cliTotalTokens",
1041
1131
  "cliEstimatedCostUsd",
@@ -1055,16 +1145,17 @@ def _collect_cli_runtime_usage(
1055
1145
  *,
1056
1146
  incremental: bool = True,
1057
1147
  ) -> dict:
1148
+ state["additionalWorkerUsage"] = unrostered_usage_workers(state)
1058
1149
  windows_by_worker = _codex_worker_windows(project_root, state)
1059
1150
  run_window: tuple[str | None, str | None] = (None, None)
1060
1151
  if team_state_path is not None:
1061
1152
  run_window = resolve_run_window(team_state_path, state)
1062
- for worker in state.get("workers", []):
1153
+ for worker in accounting_workers(state):
1063
1154
  if not isinstance(worker, dict):
1064
1155
  continue
1065
1156
  worker_id = str(worker.get("workerId") or "").strip()
1066
1157
  records = [
1067
- dict(record) for record in worker_dispatch_records(state, worker_id)
1158
+ dict(record) for record in usage_dispatch_records(state, worker_id)
1068
1159
  ] if worker_id else []
1069
1160
  provider = _cli_assignment_provider(worker)
1070
1161
  if provider:
@@ -1090,6 +1181,7 @@ def _collect_cli_runtime_usage(
1090
1181
  f"{worker.get('provider') or worker.get('agent') or worker.get('workerId')}"
1091
1182
  )
1092
1183
  state["leadUsage"] = _cli_lead_usage(state, project_root, team_state_path)
1184
+ _remove_shared_codex_usage(state)
1093
1185
  _populate_usage_summary(state, team_name=resolve_team_name(state),
1094
1186
  sessions_found=0, needle_source="none")
1095
1187
  return state
@@ -1130,7 +1222,7 @@ def _cli_lead_usage(
1130
1222
  )
1131
1223
  worker_paths = {
1132
1224
  Path(path)
1133
- for worker in state.get("workers") or []
1225
+ for worker in accounting_workers(state)
1134
1226
  if isinstance(worker, dict)
1135
1227
  for path in ((worker.get("usage") or {}).get("cliSessionPaths") or [])
1136
1228
  }
@@ -1222,17 +1314,7 @@ def _pinned_cli_lead_session(
1222
1314
  return None
1223
1315
 
1224
1316
 
1225
- def _populate_usage_summary(
1226
- state: dict,
1227
- *,
1228
- team_name: str,
1229
- sessions_found: int,
1230
- unattributed_sessions: list[str] | None = None,
1231
- unattributed_usage: dict[str, Any] | None = None,
1232
- needle_source: str = "observed-team-names",
1233
- ) -> None:
1234
- workers = state.get("workers", [])
1235
- lead = state.get("leadUsage") or {}
1317
+ def _usage_summary_totals(lead: dict, workers: list[dict], unattributed_usage: dict | None) -> dict:
1236
1318
  lead_total = lead.get("totalTokens", 0) or 0
1237
1319
  lead_cache_read = lead.get("cacheReadTokens", 0) or 0
1238
1320
  lead_billable = lead.get("billableEquivalentTokens", 0) or 0
@@ -1247,7 +1329,28 @@ def _populate_usage_summary(
1247
1329
  worker_cache_read += unattributed_usage.get("cacheReadTokens", 0) or 0
1248
1330
  worker_billable += unattributed_usage.get("billableEquivalentTokens", 0) or 0
1249
1331
  worker_cost += unattributed_usage.get("estimatedCostUsd", 0) or 0
1332
+ cli_cost += unattributed_usage.get("cliEstimatedCostUsd", 0) or 0
1333
+
1334
+ return {
1335
+ "leadTotalTokens": lead_total,
1336
+ "workerTotalTokens": worker_total,
1337
+ "grandTotalTokens": lead_total + worker_total,
1338
+ "leadCacheReadTokens": lead_cache_read,
1339
+ "workerCacheReadTokens": worker_cache_read,
1340
+ "grandCacheReadTokens": lead_cache_read + worker_cache_read,
1341
+ "leadBillableEquivalentTokens": lead_billable,
1342
+ "workerBillableEquivalentTokens": worker_billable,
1343
+ "grandBillableEquivalentTokens": lead_billable + worker_billable,
1344
+ "estimatedCostUsd": {
1345
+ "lead": round(lead_cost, 4),
1346
+ "claudeWorkers": round(worker_cost, 4),
1347
+ "cliWorkers": round(cli_cost, 4),
1348
+ "grandTotal": round(lead_cost + worker_cost + cli_cost, 4),
1349
+ },
1350
+ }
1351
+
1250
1352
 
1353
+ def _unmatched_usage_models(lead: dict, workers: list[dict]) -> list[str]:
1251
1354
  unmatched_models: list[str] = []
1252
1355
  if (
1253
1356
  lead.get("model")
@@ -1270,27 +1373,28 @@ def _populate_usage_summary(
1270
1373
  unmatched_models.append(u["model"])
1271
1374
  if u.get("cliModel") and u.get("cliEstimatedCostUsd") is None and (u.get("cliTotalTokens") or 0) > 0:
1272
1375
  unmatched_models.append(u["cliModel"])
1376
+ return sorted(set(unmatched_models))
1377
+
1378
+
1379
+ def _populate_usage_summary(
1380
+ state: dict,
1381
+ *,
1382
+ team_name: str,
1383
+ sessions_found: int,
1384
+ unattributed_sessions: list[str] | None = None,
1385
+ unattributed_usage: dict[str, Any] | None = None,
1386
+ needle_source: str = "observed-team-names",
1387
+ ) -> None:
1388
+ workers = accounting_workers(state)
1389
+ unattributed_usage = _unattributed_usage(state, unattributed_usage)
1390
+ lead = state.get("leadUsage") or {}
1273
1391
  state["usageSummary"] = {
1274
- "leadTotalTokens": lead_total,
1275
- "workerTotalTokens": worker_total,
1276
- "grandTotalTokens": lead_total + worker_total,
1277
- "leadCacheReadTokens": lead_cache_read,
1278
- "workerCacheReadTokens": worker_cache_read,
1279
- "grandCacheReadTokens": lead_cache_read + worker_cache_read,
1280
- "leadBillableEquivalentTokens": lead_billable,
1281
- "workerBillableEquivalentTokens": worker_billable,
1282
- "grandBillableEquivalentTokens": lead_billable + worker_billable,
1283
- "estimatedCostUsd": {
1284
- "lead": round(lead_cost, 4),
1285
- "claudeWorkers": round(worker_cost, 4),
1286
- "cliWorkers": round(cli_cost, 4),
1287
- "grandTotal": round(lead_cost + worker_cost + cli_cost, 4),
1288
- },
1392
+ **_usage_summary_totals(lead, workers, unattributed_usage),
1289
1393
  "collectedAt": utc_now(),
1290
1394
  "teamName": team_name,
1291
1395
  "needleSource": needle_source,
1292
1396
  "sessionsFound": sessions_found,
1293
- "unmatchedModels": sorted(set(unmatched_models)),
1397
+ "unmatchedModels": _unmatched_usage_models(lead, workers),
1294
1398
  "unattributedTeamSessions": unattributed_sessions or [],
1295
1399
  "unattributedWorkerUsage": unattributed_usage,
1296
1400
  "definitions": {
@@ -1302,18 +1406,10 @@ def _populate_usage_summary(
1302
1406
  }
1303
1407
 
1304
1408
 
1305
- def collect_claude_runtime_usage(
1306
- team_state_path: Path,
1307
- project_root: Path | None = None,
1308
- *,
1309
- incremental: bool = True,
1310
- ) -> dict:
1311
- # incremental: 세션 jsonl 스캔에 byte cursor 캐시 사용 (P6). 캐시는 윈도우
1312
- # 적용 전 이벤트를 저장하므로 결과는 전체 스캔과 동일 — False 는 캐시 경로를
1313
- # 완전히 우회하는 정확성 폴백(CLI --no-cache).
1314
- state = json.loads(team_state_path.read_text())
1315
- cwd = project_root or _infer_project_root(team_state_path, state)
1316
- run_since, run_until = resolve_run_window(team_state_path, state)
1409
+ def _claude_sessions_for_run(
1410
+ state: dict, team_state_path: Path, cwd: Path,
1411
+ run_since: str | None, run_until: str | None, incremental: bool,
1412
+ ) -> tuple:
1317
1413
  team_name = resolve_team_name(state)
1318
1414
  lead_sid = (state.get("lead") or {}).get("sessionId")
1319
1415
 
@@ -1341,6 +1437,13 @@ def collect_claude_runtime_usage(
1341
1437
  if ts and (found_earliest is None or ts < found_earliest):
1342
1438
  found_earliest = ts
1343
1439
  run_since = relax_window_start(run_since, found_earliest, floor)
1440
+ return team_name, lead_sid, claude_sessions, run_since, needle_source
1441
+
1442
+
1443
+ def _claude_session_evidence(
1444
+ state: dict, cwd: Path, claude_sessions: dict, lead_sid: str | None,
1445
+ run_since: str | None, run_until: str | None, incremental: bool,
1446
+ ) -> tuple:
1344
1447
  by_agent: dict[str, list[tuple[str, Path, dict]]] = {}
1345
1448
  lead_path: Path | None = None
1346
1449
  # Team-tagged non-lead sessions that carry no agentName. These are almost
@@ -1375,7 +1478,7 @@ def collect_claude_runtime_usage(
1375
1478
  if team_create_status in ("implicit", "skipped", "error"):
1376
1479
  worker_prefix_pool = [
1377
1480
  prefix
1378
- for w in state.get("workers", [])
1481
+ for w in accounting_workers(state)
1379
1482
  for prefix in match_prefixes(w.get("workerId") or "")
1380
1483
  ]
1381
1484
  subagent_parent_sids = [lead_sid] if lead_sid else []
@@ -1394,6 +1497,13 @@ def collect_claude_runtime_usage(
1394
1497
  if agent:
1395
1498
  by_agent.setdefault(agent, []).append((sid, path, totals))
1396
1499
 
1500
+ return by_agent, lead_path, unattributed_sessions, unattributed_totals
1501
+
1502
+
1503
+ def _attach_claude_lead_usage(
1504
+ state: dict, cwd: Path, lead_path: Path | None, lead_sid: str | None,
1505
+ run_since: str | None, run_until: str | None, incremental: bool,
1506
+ ) -> None:
1397
1507
  # Lead.
1398
1508
  if lead_path is not None:
1399
1509
  totals = claude_session_totals(lead_path, since=run_since, until=run_until,
@@ -1406,84 +1516,93 @@ def collect_claude_runtime_usage(
1406
1516
  f"lead session jsonl not found under {claude_project_dir(cwd)} (sessionId={lead_sid})"
1407
1517
  )
1408
1518
 
1519
+
1520
+
1521
+ def _matching_claude_worker_sessions(
1522
+ worker: dict, state: dict, cwd: Path, by_agent: dict,
1523
+ run_since: str | None, run_until: str | None, incremental: bool,
1524
+ ) -> tuple:
1525
+ sessions_dir = claude_project_dir(cwd)
1526
+ worker_id = worker.get("workerId")
1527
+ prefixes = match_prefixes(worker_id) if worker_id else []
1528
+
1529
+ # pane 워커는 별도 `claude -p` 프로세스라 jsonl 에 agentName 도 teamName
1530
+ # 도 안 남긴다 — 아래 prefix 경로로는 영원히 매칭되지 않는다. dispatch 가
1531
+ # 발급해 적어 둔 이 id 가 그 세션을 짚는 유일한 결정적 단서다. 재시도는
1532
+ # attempt 마다 새 세션이므로 기록된 id 를 전부 합산한다.
1533
+ # 헬퍼에서 worker_id=None 은 "run 전체"라, 이름 없는 워커에 그대로 넘기면
1534
+ # 그 워커가 run 의 모든 세션을 흡수한다.
1535
+ dispatched_sids = usage_session_ids(state, worker_id) if worker_id else []
1536
+ matched: list[tuple[str, Path, dict]] = []
1537
+ matched_sids: set[str] = set()
1538
+ for sid in dispatched_sids:
1539
+ path = sessions_dir / f"{sid}.jsonl"
1540
+ if not path.is_file():
1541
+ continue
1542
+ totals = claude_session_totals(path, since=run_since, until=run_until,
1543
+ incremental=incremental)
1544
+ # 창 밖 세션은 여기서 버린다 — agentName 발견 경로가 위에서 같은
1545
+ # `startedAt` 검사로 거르는 것과 같은 이유다. 넣으면 0 토큰 totals 가
1546
+ # usage_block 을 타고 "이 워커는 0 을 썼다"로 보고되어, unavailable 로
1547
+ # 남아야 할 상태를 허위 0 이 덮는다.
1548
+ if not totals.get("startedAt"):
1549
+ continue
1550
+ matched.append((sid, path, totals))
1551
+ matched_sids.add(sid)
1552
+
1553
+ # 조건부 폴백이 아니라 합집합이다. 한 워커의 attempt 1 이 pane(sid 로만
1554
+ # 찾힌다), attempt 2 가 in-process 서브에이전트(agentName 으로만 찾힌다)일
1555
+ # 수 있고, `if not matched:` 로 두면 sid 가 하나라도 맞는 순간 attempt 2 의
1556
+ # 토큰이 통째로 누락된다. 양쪽에서 온 같은 세션은 sid 로 한 번만 센다.
1557
+ for agent_name, entries in by_agent.items():
1558
+ if not agent_matches(agent_name, prefixes):
1559
+ continue
1560
+ for entry in entries:
1561
+ if entry[0] in matched_sids:
1562
+ continue
1563
+ # team-needle 경로는 창 검사 없이 by_agent 를 채운다. 같은 리드
1564
+ # 세션의 다음 run 이 띄운 워커도 같은 needle 에 걸리는데, 창 밖이라
1565
+ # startedAt 이 없어 아래 정렬에서 맨 앞에 서고 `sessionId` 를
1566
+ # 차지한다 — 토큰은 0 이라 합계는 안 틀리고 리포트가 가리키는
1567
+ # 세션만 남의 것이 된다.
1568
+ if not entry[2].get("startedAt"):
1569
+ continue
1570
+ matched.append(entry)
1571
+ matched_sids.add(entry[0])
1572
+
1573
+ return matched, dispatched_sids, prefixes
1574
+
1575
+
1576
+ def _collect_claude_workers(
1577
+ state: dict, cwd: Path, by_agent: dict,
1578
+ run_since: str | None, run_until: str | None, incremental: bool,
1579
+ ) -> set[str]:
1409
1580
  # Workers — dispatch 가 기록한 세션 id 로 짚은 세션과 agentName prefix 로
1410
1581
  # 찾은 세션의 합집합을 합산한다(재배치 `-002`, convergence `-reverify-r1`,
1411
1582
  # implementation `-executor`, report-writer `-impl` / `-2` 등).
1412
- sessions_dir = claude_project_dir(cwd)
1413
1583
  # sid 로 귀속한 세션 id 전체 — 아래 unattributed 폴드에서 빼는 데 쓴다.
1414
1584
  attributed_sids: set[str] = set()
1415
1585
  cli_windows_by_worker = _codex_worker_windows(cwd, state)
1416
- for worker in state.get("workers", []):
1586
+ for worker in accounting_workers(state):
1417
1587
  worker_id = worker.get("workerId")
1418
- agent = worker.get("agent")
1419
- prefixes = match_prefixes(worker_id) if worker_id else []
1420
-
1421
- # pane 워커는 별도 `claude -p` 프로세스라 jsonl 에 agentName 도 teamName
1422
- # 도 안 남긴다 — 아래 prefix 경로로는 영원히 매칭되지 않는다. dispatch 가
1423
- # 발급해 적어 둔 이 id 가 그 세션을 짚는 유일한 결정적 단서다. 재시도는
1424
- # attempt 마다 새 세션이므로 기록된 id 를 전부 합산한다.
1425
- # 헬퍼에서 worker_id=None 은 "run 전체"라, 이름 없는 워커에 그대로 넘기면
1426
- # 그 워커가 run 의 모든 세션을 흡수한다.
1427
- dispatched_sids = worker_session_ids(state, worker_id) if worker_id else []
1428
- matched: list[tuple[str, Path, dict]] = []
1429
- matched_sids: set[str] = set()
1430
- for sid in dispatched_sids:
1431
- path = sessions_dir / f"{sid}.jsonl"
1432
- if not path.is_file():
1433
- continue
1434
- totals = claude_session_totals(path, since=run_since, until=run_until,
1435
- incremental=incremental)
1436
- # 창 밖 세션은 여기서 버린다 — agentName 발견 경로가 위에서 같은
1437
- # `startedAt` 검사로 거르는 것과 같은 이유다. 넣으면 0 토큰 totals 가
1438
- # usage_block 을 타고 "이 워커는 0 을 썼다"로 보고되어, unavailable 로
1439
- # 남아야 할 상태를 허위 0 이 덮는다.
1440
- if not totals.get("startedAt"):
1441
- continue
1442
- matched.append((sid, path, totals))
1443
- matched_sids.add(sid)
1444
- attributed_sids.add(sid)
1445
-
1446
- # 조건부 폴백이 아니라 합집합이다. 한 워커의 attempt 1 이 pane(sid 로만
1447
- # 찾힌다), attempt 2 가 in-process 서브에이전트(agentName 으로만 찾힌다)일
1448
- # 수 있고, `if not matched:` 로 두면 sid 가 하나라도 맞는 순간 attempt 2 의
1449
- # 토큰이 통째로 누락된다. 양쪽에서 온 같은 세션은 sid 로 한 번만 센다.
1450
- for agent_name, entries in by_agent.items():
1451
- if not agent_matches(agent_name, prefixes):
1452
- continue
1453
- for entry in entries:
1454
- if entry[0] in matched_sids:
1455
- continue
1456
- # team-needle 경로는 창 검사 없이 by_agent 를 채운다. 같은 리드
1457
- # 세션의 다음 run 이 띄운 워커도 같은 needle 에 걸리는데, 창 밖이라
1458
- # startedAt 이 없어 아래 정렬에서 맨 앞에 서고 `sessionId` 를
1459
- # 차지한다 — 토큰은 0 이라 합계는 안 틀리고 리포트가 가리키는
1460
- # 세션만 남의 것이 된다.
1461
- if not entry[2].get("startedAt"):
1462
- continue
1463
- matched.append(entry)
1464
- matched_sids.add(entry[0])
1465
-
1588
+ matched, dispatched_sids, prefixes = _matching_claude_worker_sessions(
1589
+ worker, state, cwd, by_agent, run_since, run_until, incremental,
1590
+ )
1591
+ attributed_sids.update(sid for sid, _path, _totals in matched)
1466
1592
  if not matched:
1467
1593
  provider = _cli_assignment_provider(worker)
1468
1594
  if provider and str(worker.get("runner") or "") == "cli-wrapper":
1469
1595
  worker["usage"] = _worker_cli_usage_block(
1470
- provider=provider,
1471
- project_root=cwd,
1472
- worker=worker,
1473
- records=[
1474
- dict(record)
1475
- for record in worker_dispatch_records(
1476
- state, str(worker_id or "").strip()
1477
- )
1478
- ] if worker_id else [],
1479
- fallback_windows=cli_windows_by_worker.get(
1480
- str(worker_id or "").strip(), []
1481
- ),
1596
+ provider=provider, project_root=cwd, worker=worker,
1597
+ records=[dict(record) for record in usage_dispatch_records(state, worker_id)]
1598
+ if worker_id else [],
1599
+ fallback_windows=cli_windows_by_worker.get(worker_id, []),
1482
1600
  )
1483
1601
  else:
1484
- worker["usage"] = na_block(
1602
+ worker["usage"] = _claude_wrapper_status_usage(
1603
+ cwd, usage_dispatch_records(state, worker_id) if worker_id else [],
1485
1604
  "no Claude session jsonl in the run window for dispatched "
1486
- f"sessionIds {dispatched_sids} or agentName prefixes {prefixes}"
1605
+ f"sessionIds {dispatched_sids} or agentName prefixes {prefixes}",
1487
1606
  )
1488
1607
  continue
1489
1608
 
@@ -1501,21 +1620,20 @@ def collect_claude_runtime_usage(
1501
1620
  # attributable provider transcript usage without treating absence as zero.
1502
1621
  provider = _cli_assignment_provider(worker)
1503
1622
  if provider:
1504
- windows, status_path, used_fallback = _worker_cli_windows(
1505
- cwd,
1506
- worker,
1507
- [(aggregate.get("startedAt") or "", aggregate.get("endedAt") or "")],
1508
- )
1509
- _attach_cli_usage(
1510
- block,
1511
- provider,
1512
- cwd,
1513
- windows,
1514
- status_path,
1515
- used_fallback,
1623
+ cli = _worker_cli_usage_block(
1624
+ provider=provider, project_root=cwd, worker=worker,
1625
+ records=[dict(record) for record in usage_dispatch_records(state, worker_id)],
1626
+ fallback_windows=cli_windows_by_worker.get(worker_id, []),
1516
1627
  )
1628
+ _attach_cli_usage(block, cli)
1517
1629
  worker["usage"] = block
1518
1630
 
1631
+ return attributed_sids
1632
+
1633
+
1634
+ def _unattributed_claude_usage(
1635
+ unattributed_sessions: list[str], unattributed_totals: list[dict], attributed_sids: set[str],
1636
+ ) -> tuple[list[str], dict | None]:
1519
1637
  # Fold team-tagged worker sessions that carry no agentName into the worker
1520
1638
  # pool. They cannot be mapped to a specific workerId (so each named worker
1521
1639
  # row above stays `unavailable`), but the tokens are real team-worker spend —
@@ -1551,12 +1669,31 @@ def collect_claude_runtime_usage(
1551
1669
  "cannot be mapped to a specific workerId."
1552
1670
  )
1553
1671
 
1672
+ return unattributed_sessions, unattributed_usage
1673
+
1674
+
1675
+ def collect_claude_runtime_usage(
1676
+ team_state_path: Path, project_root: Path | None = None, *, incremental: bool = True,
1677
+ ) -> dict:
1678
+ state = json.loads(team_state_path.read_text())
1679
+ state["additionalWorkerUsage"] = unrostered_usage_workers(state)
1680
+ cwd = project_root or _infer_project_root(team_state_path, state)
1681
+ run_since, run_until = resolve_run_window(team_state_path, state)
1682
+ team_name, lead_sid, sessions, run_since, needle_source = _claude_sessions_for_run(
1683
+ state, team_state_path, cwd, run_since, run_until, incremental,
1684
+ )
1685
+ by_agent, lead_path, unattributed_sessions, unattributed_totals = _claude_session_evidence(
1686
+ state, cwd, sessions, lead_sid, run_since, run_until, incremental,
1687
+ )
1688
+ _attach_claude_lead_usage(state, cwd, lead_path, lead_sid, run_since, run_until, incremental)
1689
+ attributed = _collect_claude_workers(state, cwd, by_agent, run_since, run_until, incremental)
1690
+ unattributed_sessions, unattributed_usage = _unattributed_claude_usage(
1691
+ unattributed_sessions, unattributed_totals, attributed,
1692
+ )
1693
+ _remove_shared_codex_usage(state)
1554
1694
  _populate_usage_summary(
1555
- state,
1556
- team_name=team_name,
1557
- sessions_found=len(claude_sessions),
1558
- unattributed_sessions=unattributed_sessions,
1559
- unattributed_usage=unattributed_usage,
1695
+ state, team_name=team_name, sessions_found=len(sessions),
1696
+ unattributed_sessions=unattributed_sessions, unattributed_usage=unattributed_usage,
1560
1697
  needle_source=needle_source,
1561
1698
  )
1562
1699
  return state
@@ -18,9 +18,8 @@ from pathlib import Path
18
18
 
19
19
  from okstra_project.dirs import okstra_home
20
20
 
21
- # v2: usage 이벤트에 `p`(lead PROGRESS 마커) 추가 v1 캐시는 이미 스캔한
22
- # 구간의 마커가 없으므로 폐기하고 전체 재스캔한다(fail-open).
23
- CACHE_SCHEMA_VERSION = 2
21
+ # 응답 ID가 없는 이전 캐시는 중복 여부를 복구할 없어 원본부터 재스캔한다.
22
+ CACHE_SCHEMA_VERSION = 3
24
23
  IDENTITY_PREFIX_BYTES = 256
25
24
  MAX_NEEDLES = 16
26
25