okstra 0.163.2 → 0.165.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 (167) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture.md +24 -15
  3. package/docs/cli.md +15 -8
  4. package/docs/for-ai/README.md +2 -2
  5. package/docs/for-ai/skills/okstra-inspect.md +2 -2
  6. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  7. package/docs/project-structure-overview.md +22 -14
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/workers/antigravity-worker.md +9 -7
  11. package/runtime/agents/workers/claude-worker.md +1 -0
  12. package/runtime/agents/workers/codex-worker.md +9 -7
  13. package/runtime/agents/workers/grok-worker.md +6 -4
  14. package/runtime/agents/workers/kimi-worker.md +6 -4
  15. package/runtime/bin/lib/okstra/cli.sh +5 -0
  16. package/runtime/bin/lib/okstra/globals.sh +2 -0
  17. package/runtime/bin/lib/okstra/usage.sh +5 -5
  18. package/runtime/bin/okstra-antigravity-exec.sh +1 -340
  19. package/runtime/bin/okstra-claude-exec.sh +1 -178
  20. package/runtime/bin/okstra-codex-exec.sh +1 -467
  21. package/runtime/bin/okstra-provider-exec.py +165 -190
  22. package/runtime/bin/okstra-trace-cleanup.sh +14 -7
  23. package/runtime/bin/okstra-wrapper-status.py +26 -19
  24. package/runtime/bin/okstra.sh +87 -91
  25. package/runtime/prompts/lead/adapters/cmux.md +2 -2
  26. package/runtime/prompts/lead/convergence.md +36 -8
  27. package/runtime/prompts/lead/okstra-lead-contract.md +24 -1
  28. package/runtime/prompts/lead/plan-body-verification.md +9 -1
  29. package/runtime/prompts/lead/report-writer.md +1 -0
  30. package/runtime/prompts/lead/team-contract.md +3 -3
  31. package/runtime/prompts/profiles/_common-contract.md +9 -1
  32. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  33. package/runtime/prompts/profiles/_implementation-diff-review.md +3 -1
  34. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  35. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  36. package/runtime/prompts/profiles/implementation-planning.md +6 -4
  37. package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
  38. package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
  39. package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
  40. package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
  41. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
  42. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
  43. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
  44. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  45. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
  46. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
  47. package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
  48. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
  49. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
  50. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
  51. package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +112 -1
  52. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
  53. package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
  54. package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
  55. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
  56. package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
  57. package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +53 -1
  58. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
  59. package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
  60. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
  61. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
  62. package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
  63. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
  64. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +183 -0
  65. package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
  66. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +110 -0
  67. package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
  68. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +84 -0
  69. package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
  70. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +76 -0
  71. package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
  72. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +80 -0
  73. package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
  74. package/runtime/python/okstra_ctl/application/__init__.py +1 -0
  75. package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
  76. package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
  77. package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
  78. package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
  79. package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
  80. package/runtime/python/okstra_ctl/application/start_run.py +21 -0
  81. package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
  82. package/runtime/python/okstra_ctl/dispatch_core.py +244 -29
  83. package/runtime/python/okstra_ctl/dispatch_state.py +27 -0
  84. package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
  85. package/runtime/python/okstra_ctl/domain/host.py +100 -0
  86. package/runtime/python/okstra_ctl/domain/provider.py +70 -0
  87. package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
  88. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
  89. package/runtime/python/okstra_ctl/domain/worker_exec.py +102 -0
  90. package/runtime/python/okstra_ctl/domain/worker_role.py +34 -0
  91. package/runtime/python/okstra_ctl/domain/worker_stream.py +261 -0
  92. package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
  93. package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
  94. package/runtime/python/okstra_ctl/incremental_scope.py +16 -4
  95. package/runtime/python/okstra_ctl/models.py +54 -269
  96. package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
  97. package/runtime/python/okstra_ctl/ports/host.py +32 -0
  98. package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
  99. package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
  100. package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
  101. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
  102. package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
  103. package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
  104. package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
  105. package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
  106. package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
  107. package/runtime/python/okstra_ctl/render.py +145 -47
  108. package/runtime/python/okstra_ctl/report_html/common.py +71 -25
  109. package/runtime/python/okstra_ctl/report_html/models.py +5 -0
  110. package/runtime/python/okstra_ctl/report_html/render.py +1 -1
  111. package/runtime/python/okstra_ctl/report_html/run_usage.py +19 -0
  112. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -0
  113. package/runtime/python/okstra_ctl/report_views.py +44 -16
  114. package/runtime/python/okstra_ctl/run.py +80 -58
  115. package/runtime/python/okstra_ctl/session.py +1 -1
  116. package/runtime/python/okstra_ctl/stage_citations.py +52 -15
  117. package/runtime/python/okstra_ctl/team.py +44 -32
  118. package/runtime/python/okstra_ctl/user_response.py +45 -29
  119. package/runtime/python/okstra_ctl/wizard.py +175 -73
  120. package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
  121. package/runtime/python/okstra_ctl/worker_prompt_policy.py +10 -3
  122. package/runtime/python/okstra_ctl/worker_request.py +140 -0
  123. package/runtime/python/okstra_ctl/worker_runner.py +622 -0
  124. package/runtime/python/okstra_token_usage/collect.py +42 -7
  125. package/runtime/python/okstra_token_usage/report.py +42 -0
  126. package/runtime/python/okstra_token_usage/task_totals.py +88 -0
  127. package/runtime/schemas/final-report-v1.0.schema.json +4040 -1066
  128. package/runtime/schemas/final-report-v2.0.schema.json +5673 -1412
  129. package/runtime/skills/okstra-inspect/SKILL.md +1 -2
  130. package/runtime/skills/okstra-inspect/facets/logs.md +5 -5
  131. package/runtime/skills/okstra-inspect/facets/run-audit.md +3 -3
  132. package/runtime/skills/okstra-run/SKILL.md +74 -29
  133. package/runtime/skills/okstra-user-response/SKILL.md +15 -5
  134. package/runtime/templates/implementation-worker-preamble.md +1 -1
  135. package/runtime/templates/report-writer-prompt-preamble.md +1 -0
  136. package/runtime/templates/reports/final-report.template.md +3 -3
  137. package/runtime/templates/reports/html/assets/base.css +8 -4
  138. package/runtime/templates/reports/html/base.template.html +12 -6
  139. package/runtime/templates/reports/html/i18n/en.json +32 -7
  140. package/runtime/templates/reports/html/i18n/ko.json +32 -7
  141. package/runtime/templates/reports/html/macros/forms.html +9 -3
  142. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +14 -19
  143. package/runtime/templates/reports/report.js +59 -26
  144. package/runtime/templates/reports/user-response.template.md +12 -8
  145. package/runtime/templates/worker-prompt-preamble.md +1 -1
  146. package/runtime/validators/validate-implementation-plan-stages.py +17 -22
  147. package/runtime/validators/validate-report-views.py +0 -39
  148. package/runtime/validators/validate-run.py +96 -14
  149. package/runtime/validators/validate_session_conformance.py +69 -3
  150. package/src/cli-registry.mjs +0 -7
  151. package/src/commands/execute/render-bundle.mjs +4 -4
  152. package/src/commands/execute/run.mjs +8 -25
  153. package/src/commands/execute/wizard.mjs +33 -13
  154. package/src/commands/lifecycle/doctor.mjs +10 -10
  155. package/src/commands/lifecycle/install.mjs +53 -30
  156. package/src/commands/lifecycle/preflight.mjs +14 -4
  157. package/src/lib/host-registry-client.mjs +176 -0
  158. package/src/lib/runtime-manifest.mjs +6 -8
  159. package/runtime/bin/okstra-wrapper-agy-stream.py +0 -61
  160. package/runtime/python/okstra_ctl/error_issue.py +0 -640
  161. package/runtime/python/okstra_ctl/issue_signals.py +0 -186
  162. package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
  163. package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
  164. package/runtime/skills/okstra-inspect/facets/error-issue.md +0 -77
  165. package/src/commands/inspect/error-issue.mjs +0 -27
  166. package/src/lib/runtime-readiness.mjs +0 -90
  167. package/src/lib/runtime-resolver.mjs +0 -123
@@ -22,7 +22,6 @@ from .antigravity import (
22
22
  from .paths import claude_project_dir, utc_now
23
23
  from .pricing import antigravity_cost_usd, provider_cost_usd
24
24
  from okstra_ctl.models import provider_wrappers
25
- from okstra_ctl.lead_runtime import ARTIFACT_ONLY_LEAD_RUNTIMES
26
25
  from okstra_ctl.wrapper_status import read_wrapper_status, status_path_for_prompt
27
26
 
28
27
 
@@ -705,14 +704,17 @@ def _populate_usage_summary(
705
704
  workers = state.get("workers", [])
706
705
  lead = state.get("leadUsage") or {}
707
706
  lead_total = lead.get("totalTokens", 0) or 0
707
+ lead_cache_read = lead.get("cacheReadTokens", 0) or 0
708
708
  lead_billable = lead.get("billableEquivalentTokens", 0) or 0
709
709
  lead_cost = lead.get("estimatedCostUsd", 0) or 0
710
710
  worker_total = sum((w.get("usage") or {}).get("totalTokens", 0) or 0 for w in workers)
711
+ worker_cache_read = sum((w.get("usage") or {}).get("cacheReadTokens", 0) or 0 for w in workers)
711
712
  worker_billable = sum((w.get("usage") or {}).get("billableEquivalentTokens", 0) or 0 for w in workers)
712
713
  worker_cost = sum((w.get("usage") or {}).get("estimatedCostUsd", 0) or 0 for w in workers)
713
714
  cli_cost = sum((w.get("usage") or {}).get("cliEstimatedCostUsd", 0) or 0 for w in workers)
714
715
  if unattributed_usage is not None:
715
716
  worker_total += unattributed_usage.get("totalTokens", 0) or 0
717
+ worker_cache_read += unattributed_usage.get("cacheReadTokens", 0) or 0
716
718
  worker_billable += unattributed_usage.get("billableEquivalentTokens", 0) or 0
717
719
  worker_cost += unattributed_usage.get("estimatedCostUsd", 0) or 0
718
720
 
@@ -734,6 +736,9 @@ def _populate_usage_summary(
734
736
  "leadTotalTokens": lead_total,
735
737
  "workerTotalTokens": worker_total,
736
738
  "grandTotalTokens": lead_total + worker_total,
739
+ "leadCacheReadTokens": lead_cache_read,
740
+ "workerCacheReadTokens": worker_cache_read,
741
+ "grandCacheReadTokens": lead_cache_read + worker_cache_read,
737
742
  "leadBillableEquivalentTokens": lead_billable,
738
743
  "workerBillableEquivalentTokens": worker_billable,
739
744
  "grandBillableEquivalentTokens": lead_billable + worker_billable,
@@ -751,26 +756,28 @@ def _populate_usage_summary(
751
756
  "unattributedTeamSessions": unattributed_sessions or [],
752
757
  "unattributedWorkerUsage": unattributed_usage,
753
758
  "definitions": {
754
- "totalTokens": "Sum of input + output + cache_creation + cache_read tokens (raw processed volume; matches Anthropic API breakdown). Cache reads are 95%+ in long sessions.",
759
+ "totalTokens": "Sum of input + output + cache_creation tokens — the volume the session put through the model once. cache_read is excluded and reported separately as cacheReadTokens: a session re-reads its whole context from cache every turn, so folding it in here would count the same tokens once per turn.",
760
+ "cacheReadTokens": "Context re-read from cache. Billed at 0.1x base input, so it lands in billableEquivalentTokens and in the cost even though it is not part of totalTokens.",
755
761
  "billableEquivalentTokens": "Tokens normalized to base-input-price units (cache_creation_5m x1.25, cache_creation_1h x2.0, cache_read x0.1, output x5). 5m vs 1h is split from usage.cache_creation when the API breakdown is present; otherwise all cache_creation falls into 5m.",
756
762
  "estimatedCostUsd": "USD cost using public list pricing for the model recorded in the session. cliWorkers covers attributable registered-provider CLI calls.",
757
763
  },
758
764
  }
759
765
 
760
766
 
761
- def collect(team_state_path: Path, project_root: Path | None = None, *,
762
- incremental: bool = True) -> dict:
767
+ def collect_claude_runtime_usage(
768
+ team_state_path: Path,
769
+ project_root: Path | None = None,
770
+ *,
771
+ incremental: bool = True,
772
+ ) -> dict:
763
773
  # incremental: 세션 jsonl 스캔에 byte cursor 캐시 사용 (P6). 캐시는 윈도우
764
774
  # 적용 전 이벤트를 저장하므로 결과는 전체 스캔과 동일 — False 는 캐시 경로를
765
775
  # 완전히 우회하는 정확성 폴백(CLI --no-cache).
766
776
  state = json.loads(team_state_path.read_text())
767
777
  cwd = project_root or _infer_project_root(team_state_path, state)
768
778
  run_since, run_until = resolve_run_window(team_state_path, state)
769
- task_key = state.get("taskKey", "")
770
779
  team_name = resolve_team_name(state)
771
780
  lead_sid = (state.get("lead") or {}).get("sessionId")
772
- if state.get("leadRuntime") in ARTIFACT_ONLY_LEAD_RUNTIMES:
773
- return _collect_cli_runtime_usage(state, cwd)
774
781
 
775
782
  # 1) Claude sessions (lead + claude-side workers). Cache totals at scan
776
783
  # time so we don't re-read the jsonl when a worker matches multiple
@@ -936,6 +943,34 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
936
943
  return state
937
944
 
938
945
 
946
+ def collect_cli_runtime_usage(
947
+ team_state_path: Path,
948
+ project_root: Path | None = None,
949
+ *,
950
+ incremental: bool = True,
951
+ ) -> dict:
952
+ state = json.loads(team_state_path.read_text())
953
+ cwd = project_root or _infer_project_root(team_state_path, state)
954
+ return _collect_cli_runtime_usage(state, cwd)
955
+
956
+
957
+ def collect(
958
+ team_state_path: Path,
959
+ project_root: Path | None = None,
960
+ *,
961
+ incremental: bool = True,
962
+ ) -> dict:
963
+ from okstra_ctl.application.collect_usage import collect_usage
964
+ from okstra_ctl.ports.usage_accounting import UsageRequest
965
+ from okstra_ctl.registry.host_registry import default_host_registry
966
+
967
+ state = json.loads(team_state_path.read_text())
968
+ cwd = project_root or _infer_project_root(team_state_path, state)
969
+ adapter = default_host_registry().resolve(str(state.get("leadRuntime") or ""))
970
+ request = UsageRequest(team_state_path, cwd, incremental)
971
+ return collect_usage(request, adapter.usage_accounting()).payload
972
+
973
+
939
974
  def _infer_project_root(team_state_path: Path, state: dict) -> Path:
940
975
  rel = state.get("runDirectoryPath") or ""
941
976
  p = team_state_path.resolve().parent
@@ -24,6 +24,8 @@ from okstra_ctl.render_final_report import ( # noqa: E402
24
24
  render_to_file,
25
25
  )
26
26
 
27
+ from .task_totals import task_cumulative_usage # noqa: E402
28
+
27
29
 
28
30
  class SubstituteRefusedError(RuntimeError):
29
31
  """Raised when substitution would write zero-only token cells.
@@ -89,6 +91,8 @@ def _populate_execution_row(row: dict, source: dict) -> None:
89
91
  return
90
92
  if "totalTokens" in usage:
91
93
  row["totalTokens"] = usage["totalTokens"]
94
+ if "cacheReadTokens" in usage:
95
+ row["cacheReadTokens"] = usage["cacheReadTokens"]
92
96
  if "billableEquivalentTokens" in usage:
93
97
  row["billableTokens"] = usage["billableEquivalentTokens"]
94
98
  if "estimatedCostUsd" in usage:
@@ -114,6 +118,7 @@ def _worker_detail_row(label: str, usage: dict) -> dict:
114
118
  return {
115
119
  "label": label,
116
120
  "totalTokens": None,
121
+ "cacheReadTokens": None,
117
122
  "billableTokens": None,
118
123
  "costUsd": None,
119
124
  "cliTotalTokens": None,
@@ -122,6 +127,7 @@ def _worker_detail_row(label: str, usage: dict) -> dict:
122
127
  return {
123
128
  "label": label,
124
129
  "totalTokens": usage.get("totalTokens"),
130
+ "cacheReadTokens": usage.get("cacheReadTokens"),
125
131
  "billableTokens": usage.get("billableEquivalentTokens"),
126
132
  "costUsd": usage.get("estimatedCostUsd"),
127
133
  "cliTotalTokens": usage.get("cliTotalTokens"),
@@ -129,6 +135,35 @@ def _worker_detail_row(label: str, usage: dict) -> dict:
129
135
  }
130
136
 
131
137
 
138
+ def _cache_reads(summary: dict, team_state: dict) -> dict[str, int]:
139
+ """Lead / worker / grand cache-read totals for this run.
140
+
141
+ The figure joined `usageSummary` after most team-states on disk were
142
+ written, and re-rendering one of those reports is exactly when the reader
143
+ goes looking for the corrected table — so a state without the summary keys
144
+ is added up from the usage blocks it does carry.
145
+ """
146
+ lead = summary.get("leadCacheReadTokens")
147
+ worker = summary.get("workerCacheReadTokens")
148
+ if isinstance(lead, int) and isinstance(worker, int):
149
+ return {"lead": lead, "worker": worker, "grand": lead + worker}
150
+ lead = _cache_read_tokens(team_state.get("leadUsage"))
151
+ worker = sum(
152
+ _cache_read_tokens(entry.get("usage"))
153
+ for entry in team_state.get("workers") or []
154
+ if isinstance(entry, dict)
155
+ )
156
+ worker += _cache_read_tokens(summary.get("unattributedWorkerUsage"))
157
+ return {"lead": lead, "worker": worker, "grand": lead + worker}
158
+
159
+
160
+ def _cache_read_tokens(usage: object) -> int:
161
+ if not isinstance(usage, dict):
162
+ return 0
163
+ value = usage.get("cacheReadTokens")
164
+ return value if isinstance(value, int) else 0
165
+
166
+
132
167
  def _worker_detail_rows(team_state: dict) -> list[dict]:
133
168
  rows = []
134
169
  for worker in team_state.get("workers") or []:
@@ -178,19 +213,23 @@ def populate_data_token_cells(data_path: Path, team_state: dict) -> int:
178
213
  worker_cost = cost.get("claudeWorkers") or 0
179
214
  cli_cost = cost.get("cliWorkers") or 0
180
215
 
216
+ cache_reads = _cache_reads(summary, team_state)
181
217
  token_usage = data.setdefault("tokenUsage", {})
182
218
  token_usage.setdefault("lead", {}).update({
183
219
  "totalTokens": summary.get("leadTotalTokens"),
220
+ "cacheReadTokens": cache_reads["lead"],
184
221
  "billableTokens": summary.get("leadBillableEquivalentTokens"),
185
222
  "costUsd": lead_cost,
186
223
  })
187
224
  token_usage.setdefault("worker", {}).update({
188
225
  "totalTokens": summary.get("workerTotalTokens"),
226
+ "cacheReadTokens": cache_reads["worker"],
189
227
  "billableTokens": summary.get("workerBillableEquivalentTokens"),
190
228
  "costUsd": worker_cost,
191
229
  })
192
230
  token_usage.setdefault("grand", {}).update({
193
231
  "totalTokens": summary.get("grandTotalTokens"),
232
+ "cacheReadTokens": cache_reads["grand"],
194
233
  "billableTokens": summary.get("grandBillableEquivalentTokens"),
195
234
  # CLI tracked on its own row per the template — grand here means
196
235
  # lead + claudeWorkers, not lead + claudeWorkers + cli.
@@ -198,6 +237,9 @@ def populate_data_token_cells(data_path: Path, team_state: dict) -> int:
198
237
  })
199
238
  token_usage.setdefault("cli", {})["costUsd"] = cli_cost
200
239
  token_usage["workerDetails"] = _worker_detail_rows(team_state)
240
+ cumulative = task_cumulative_usage(data_path)
241
+ if cumulative is not None:
242
+ token_usage["taskCumulative"] = cumulative
201
243
  changes += 4
202
244
 
203
245
  # Execution Status by Agent — per-row token / cost / duration.
@@ -0,0 +1,88 @@
1
+ """What a whole task has spent, across every run of every phase.
2
+
3
+ A report's own figures cover one run, but a task reaches its plan through
4
+ several: the first pass, a clarification re-run, a conformance fix. Each of
5
+ those bills separately, and a reader who only ever sees the last one has no way
6
+ to learn what the task cost. The per-run team-states are the only record of the
7
+ earlier passes, so the total is summed from them here rather than carried
8
+ forward inside any single run's state.
9
+
10
+ Every team-state is read directly rather than through its `usageSummary`: the
11
+ summary predates the cache-read figure, so an older run would drop out of that
12
+ column while still counting in the others.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+
19
+ _TEAM_STATE_GLOB = "runs/*/state/team-state-*.json"
20
+ _SUMMED_KEYS = (
21
+ ("totalTokens", "totalTokens"),
22
+ ("cacheReadTokens", "cacheReadTokens"),
23
+ ("billableTokens", "billableEquivalentTokens"),
24
+ ("costUsd", "estimatedCostUsd"),
25
+ )
26
+
27
+
28
+ def _task_root(data_path: Path) -> Path | None:
29
+ """The task-bundle directory a report lives under.
30
+
31
+ Report path shape: `<task-id>/runs/<task-type>/reports/<name>.data.json`.
32
+ A path that does not match is not inside a task bundle — a fixture under a
33
+ tmp dir, most often — and has no sibling runs to total.
34
+ """
35
+ parents = data_path.parents
36
+ if len(parents) < 4:
37
+ return None
38
+ if parents[0].name != "reports" or parents[2].name != "runs":
39
+ return None
40
+ return parents[3]
41
+
42
+
43
+ def _usage_blocks(team_state: dict) -> list[dict]:
44
+ blocks = [team_state.get("leadUsage") or {}]
45
+ for worker in team_state.get("workers") or []:
46
+ if isinstance(worker, dict):
47
+ blocks.append(worker.get("usage") or {})
48
+ unattributed = (team_state.get("usageSummary") or {}).get("unattributedWorkerUsage")
49
+ if isinstance(unattributed, dict):
50
+ blocks.append(unattributed)
51
+ return [block for block in blocks if isinstance(block, dict)]
52
+
53
+
54
+ def _number(value: object) -> int | float:
55
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
56
+ return 0
57
+ return value
58
+
59
+
60
+ def task_cumulative_usage(data_path: Path) -> dict | None:
61
+ """Sum every measured run of this report's task, or None when there is none.
62
+
63
+ A run whose collector found no session contributes zero tokens and is left
64
+ out of `runCount` — counting it would make the average per run look cheaper
65
+ than the runs that were actually measured.
66
+ """
67
+ root = _task_root(data_path)
68
+ if root is None:
69
+ return None
70
+ totals = {name: 0 for name, _ in _SUMMED_KEYS}
71
+ run_count = 0
72
+ for state_path in sorted(root.glob(_TEAM_STATE_GLOB)):
73
+ try:
74
+ team_state = json.loads(state_path.read_text(encoding="utf-8"))
75
+ except (OSError, ValueError):
76
+ continue
77
+ if not isinstance(team_state, dict):
78
+ continue
79
+ blocks = _usage_blocks(team_state)
80
+ measured = sum(_number(block.get("totalTokens")) for block in blocks)
81
+ if measured <= 0:
82
+ continue
83
+ run_count += 1
84
+ for name, source_key in _SUMMED_KEYS:
85
+ totals[name] += sum(_number(block.get(source_key)) for block in blocks)
86
+ if not run_count:
87
+ return None
88
+ return {**totals, "runCount": run_count}