code-coordinator 0.5.46__py3-none-any.whl

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 (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
coord/usage.py ADDED
@@ -0,0 +1,753 @@
1
+ """Usage tracking: parse token/cost data from worker stream-json logs.
2
+
3
+ Provides per-assignment and per-model cost breakdowns, session burn rate,
4
+ and summary helpers for ``coord usage`` and the burn-rate warning in
5
+ ``coord status``.
6
+
7
+ Usage data is collected from two sources:
8
+
9
+ * **Local logs** — ``~/.coord/logs/<assignment_id>.log`` (stream-json).
10
+ These exist when the agent ran on the same machine as the coordinator.
11
+ * **Remote agent status** — HTTP ``/status`` on agent servers.
12
+ The agent already reports ``cost_so_far`` / ``total_cost_usd`` in its
13
+ ``list_assignments()`` response; we use those when a local log is absent.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING
23
+
24
+ from coord.models import Assignment
25
+
26
+ # Re-export COORD_DIR so callers don't need to import state directly.
27
+ from coord.state import COORD_DIR
28
+
29
+ if TYPE_CHECKING:
30
+ from coord.providers.base import Provider
31
+
32
+ _log = logging.getLogger(__name__)
33
+
34
+ LOGS_DIR = COORD_DIR / "logs"
35
+
36
+ # Burn rate threshold ($/hr) above which coord status shows a warning line.
37
+ HIGH_BURN_RATE_USD_PER_HOUR = 2.0
38
+
39
+
40
+ # ── Data classes ─────────────────────────────────────────────────────────────
41
+
42
+
43
+ @dataclass
44
+ class AssignmentUsage:
45
+ """Cost/usage data for a single assignment."""
46
+
47
+ assignment_id: str
48
+ repo_name: str
49
+ issue_number: int
50
+ issue_title: str
51
+ status: str # pending | running | done | failed
52
+ model: str | None = None
53
+ total_cost_usd: float = 0.0
54
+ num_turns: int = 0
55
+ duration_ms: int | None = None
56
+ input_tokens: int = 0
57
+ output_tokens: int = 0
58
+ cache_creation_tokens: int = 0
59
+ cache_read_tokens: int = 0
60
+
61
+ @property
62
+ def total_tokens(self) -> int:
63
+ return self.input_tokens + self.output_tokens
64
+
65
+ def duration_str(self) -> str:
66
+ if self.duration_ms is None:
67
+ return "?"
68
+ s = self.duration_ms / 1000.0
69
+ if s < 60:
70
+ return f"{s:.0f}s"
71
+ m, sec = divmod(int(s), 60)
72
+ if m < 60:
73
+ return f"{m}m {sec}s"
74
+ h, m = divmod(m, 60)
75
+ return f"{h}h {m}m"
76
+
77
+
78
+ @dataclass
79
+ class SessionUsage:
80
+ """Aggregated usage across all assignments in the current session."""
81
+
82
+ started_at: float | None = None # Unix timestamp from session.json
83
+ assignments: list[AssignmentUsage] = field(default_factory=list)
84
+
85
+ @property
86
+ def total_cost_usd(self) -> float:
87
+ return sum(a.total_cost_usd for a in self.assignments)
88
+
89
+ @property
90
+ def total_input_tokens(self) -> int:
91
+ return sum(a.input_tokens for a in self.assignments)
92
+
93
+ @property
94
+ def total_output_tokens(self) -> int:
95
+ return sum(a.output_tokens for a in self.assignments)
96
+
97
+ @property
98
+ def elapsed_hours(self) -> float | None:
99
+ """Hours since session start; None if no session timestamp."""
100
+ if self.started_at is None:
101
+ return None
102
+ elapsed_sec = time.time() - self.started_at
103
+ # Guard against negative/zero to avoid division weirdness.
104
+ return max(elapsed_sec, 60.0) / 3600.0
105
+
106
+ def burn_rate_usd_per_hour(self) -> float | None:
107
+ """$/hr based on total cost and elapsed session time.
108
+
109
+ Returns None if the session start time is unknown.
110
+ """
111
+ hours = self.elapsed_hours
112
+ if hours is None:
113
+ return None
114
+ if self.total_cost_usd == 0.0:
115
+ return 0.0
116
+ return self.total_cost_usd / hours
117
+
118
+ def cost_by_model(self) -> dict[str, float]:
119
+ """Map model name → total cost across all assignments."""
120
+ result: dict[str, float] = {}
121
+ for a in self.assignments:
122
+ key = a.model or "(unknown)"
123
+ result[key] = result.get(key, 0.0) + a.total_cost_usd
124
+ return result
125
+
126
+ def count_by_model(self) -> dict[str, int]:
127
+ """Map model name → number of assignments."""
128
+ result: dict[str, int] = {}
129
+ for a in self.assignments:
130
+ key = a.model or "(unknown)"
131
+ result[key] = result.get(key, 0) + 1
132
+ return result
133
+
134
+
135
+ # ── Log parsing ───────────────────────────────────────────────────────────────
136
+
137
+
138
+ def parse_usage_from_log(
139
+ log_path: Path,
140
+ *,
141
+ provider_name: str | None = None,
142
+ provider: "Provider | None" = None,
143
+ ) -> AssignmentUsage | None:
144
+ """Parse an :class:`AssignmentUsage` from a worker log file.
145
+
146
+ Returns ``None`` if the file doesn't exist, isn't stream-json, or can't
147
+ be parsed. The returned object has placeholder values for fields that
148
+ aren't available from the log alone (``assignment_id``, ``repo_name``,
149
+ etc.) — callers must fill those in from the board.
150
+
151
+ #1710: cost/token parsing is routed through the assignment's resolved
152
+ :class:`~coord.providers.base.Provider` (``provider.parse_log()``)
153
+ instead of assuming :mod:`coord.worker_events`'s claude-shaped parser.
154
+ *provider_name* (typically ``Assignment.provider_name``) resolves via
155
+ :func:`coord.providers.get_provider`; ``None`` defaults to
156
+ :class:`~coord.providers.claude.ClaudeProvider`, matching pre-#1710
157
+ behaviour byte-for-byte for every caller that doesn't pass it. *provider*
158
+ is an escape hatch to pass an already-constructed provider directly
159
+ (tests; bypasses name resolution).
160
+ """
161
+ from coord.worker_events import is_stream_json
162
+
163
+ if not log_path.exists():
164
+ return None
165
+ if provider is None:
166
+ from coord.providers import get_provider # noqa: PLC0415
167
+ provider = get_provider(provider_name)
168
+ if not is_stream_json(log_path):
169
+ # #1710: a provider that claims it reports cost but whose log isn't
170
+ # stream-json shaped gets NO cost/token data here — that mismatch is
171
+ # worth a loud signal rather than a silent, indistinguishable-from-
172
+ # "no cost yet" None. A provider that legitimately never reports cost
173
+ # (e.g. claude-pty, subscription-billed) sets
174
+ # capabilities().cost_reporting=False and stays silent, as before.
175
+ if provider.capabilities().cost_reporting:
176
+ _log.warning(
177
+ "parse_usage_from_log: %s is not stream-json but provider "
178
+ "%r reports capabilities().cost_reporting=True — no cost/"
179
+ "token data will be captured for this assignment (#1710)",
180
+ log_path, provider_name or "claude",
181
+ )
182
+ return None
183
+ try:
184
+ summary = provider.parse_log(log_path, tail_bytes=0)
185
+ except OSError:
186
+ return None
187
+ return AssignmentUsage(
188
+ assignment_id="", # caller fills in
189
+ repo_name="", # caller fills in
190
+ issue_number=0, # caller fills in
191
+ issue_title="", # caller fills in
192
+ status="", # caller fills in
193
+ model=summary.model_used,
194
+ total_cost_usd=summary.total_cost_usd,
195
+ num_turns=summary.num_turns,
196
+ duration_ms=summary.duration_ms,
197
+ input_tokens=summary.input_tokens,
198
+ output_tokens=summary.output_tokens,
199
+ cache_creation_tokens=summary.cache_creation_tokens,
200
+ cache_read_tokens=summary.cache_read_tokens,
201
+ )
202
+
203
+
204
+ def _assignment_to_usage(
205
+ a: Assignment,
206
+ *,
207
+ logs_dir: Path | None = None,
208
+ remote_data: dict | None = None,
209
+ ) -> AssignmentUsage:
210
+ """Build an :class:`AssignmentUsage` for *a*.
211
+
212
+ Priority: local log file > *remote_data* dict (from agent HTTP) >
213
+ Assignment.model field as a final model fallback.
214
+
215
+ *remote_data* is a dict from the agent's ``list_assignments()`` response
216
+ (e.g. ``{"cost_so_far": 0.12, "model_used": "claude-sonnet-4-6", ...}``).
217
+ """
218
+ _logs_dir = logs_dir if logs_dir is not None else LOGS_DIR
219
+ from coord.models import effective_issue_number # noqa: PLC0415
220
+
221
+ usage = AssignmentUsage(
222
+ assignment_id=a.assignment_id or "",
223
+ repo_name=a.repo_name,
224
+ # #1553: per-issue spend must land on the issue the work was really
225
+ # for. An oracle-loop acceptance slice carries the milestone's
226
+ # tracking issue in `issue_number`, so booking cost against it
227
+ # attributed every child's authoring spend to the epic.
228
+ issue_number=effective_issue_number(a),
229
+ issue_title=a.issue_title,
230
+ status=a.status,
231
+ model=a.model,
232
+ )
233
+
234
+ # Try local log first.
235
+ if a.assignment_id:
236
+ log_path = _logs_dir / f"{a.assignment_id}.log"
237
+ # #1710: thread the assignment's resolved provider name through so
238
+ # the parse uses the right provider's parse_log() rather than always
239
+ # assuming claude.
240
+ parsed = parse_usage_from_log(log_path, provider_name=a.provider_name)
241
+ if parsed is not None:
242
+ usage.model = parsed.model or a.model
243
+ usage.total_cost_usd = parsed.total_cost_usd
244
+ usage.num_turns = parsed.num_turns
245
+ usage.duration_ms = parsed.duration_ms
246
+ usage.input_tokens = parsed.input_tokens
247
+ usage.output_tokens = parsed.output_tokens
248
+ usage.cache_creation_tokens = parsed.cache_creation_tokens
249
+ usage.cache_read_tokens = parsed.cache_read_tokens
250
+ return usage
251
+
252
+ # Fall back to remote agent data if available.
253
+ if remote_data:
254
+ cost = remote_data.get("total_cost_usd") or remote_data.get("cost_so_far") or 0.0
255
+ usage.total_cost_usd = float(cost)
256
+ model_r = remote_data.get("model_used") or remote_data.get("model")
257
+ if model_r:
258
+ usage.model = str(model_r)
259
+ turns = remote_data.get("num_turns") or remote_data.get("turns")
260
+ if isinstance(turns, int):
261
+ usage.num_turns = turns
262
+
263
+ return usage
264
+
265
+
266
+ # ── Session collection ────────────────────────────────────────────────────────
267
+
268
+
269
+ def collect_usage(
270
+ board_assignments: list[Assignment],
271
+ *,
272
+ logs_dir: Path | None = None,
273
+ remote_by_id: dict[str, dict] | None = None,
274
+ ) -> list[AssignmentUsage]:
275
+ """Collect :class:`AssignmentUsage` for every assignment on the board.
276
+
277
+ *remote_by_id* maps ``assignment_id → agent_status_dict`` for assignments
278
+ whose logs live on a remote machine. Pass ``None`` (the default) to skip
279
+ remote lookups entirely — the result will still be correct for any
280
+ assignment whose log is available locally.
281
+ """
282
+ result: list[AssignmentUsage] = []
283
+ for a in board_assignments:
284
+ if not a.assignment_id:
285
+ continue
286
+ remote = (remote_by_id or {}).get(a.assignment_id)
287
+ result.append(_assignment_to_usage(a, logs_dir=logs_dir, remote_data=remote))
288
+ return result
289
+
290
+
291
+ def build_session_usage(
292
+ board_assignments: list[Assignment],
293
+ *,
294
+ logs_dir: Path | None = None,
295
+ remote_by_id: dict[str, dict] | None = None,
296
+ started_at: float | None = None,
297
+ ) -> SessionUsage:
298
+ """Build a :class:`SessionUsage` from the current board.
299
+
300
+ *started_at* should come from ``session.json["started_at"]`` (parsed to
301
+ a Unix timestamp). If not provided we fall back to the oldest
302
+ ``dispatched_at`` among the assignments.
303
+ """
304
+ if started_at is None:
305
+ # Derive from oldest dispatch time on the board.
306
+ times = [
307
+ a.dispatched_at
308
+ for a in board_assignments
309
+ if a.dispatched_at is not None
310
+ ]
311
+ started_at = min(times) if times else None
312
+
313
+ assignments = collect_usage(
314
+ board_assignments,
315
+ logs_dir=logs_dir,
316
+ remote_by_id=remote_by_id,
317
+ )
318
+ return SessionUsage(started_at=started_at, assignments=assignments)
319
+
320
+
321
+ def filter_assignments_in_window(assignments: list[Assignment], window) -> list[Assignment]:
322
+ """Filter *assignments* to those in-window (#1119 review finding #1).
323
+
324
+ An assignment is in-window if its ``dispatched_at`` **or** ``finished_at``
325
+ falls inside *window* (a :class:`~coord.usage_rollup.TimeWindow`/
326
+ ``Window``) — the same semantics :func:`coord.usage_rollup.leg_in_window`
327
+ applies to daemon-row dicts, applied here directly to ``Assignment``
328
+ timestamps (already Unix floats, no ISO parsing needed). This lets the
329
+ legacy ``coord usage`` view (no ``--by``/``--by-time``/``--by-issue``/
330
+ ``--issue``) actually honor ``--today``/``--week``/``--month``/``--since``
331
+ instead of silently ignoring them.
332
+ """
333
+ return [
334
+ a for a in assignments
335
+ if window.contains(a.dispatched_at) or window.contains(a.finished_at)
336
+ ]
337
+
338
+
339
+ # ── Rollup fetch (#1118) ──────────────────────────────────────────────────────
340
+
341
+
342
+ def fetch_usage_rows(
343
+ *,
344
+ flag_url: str | None = None,
345
+ flag_token: str | None = None,
346
+ timeout: float = 5.0,
347
+ ) -> list[dict]:
348
+ """Fetch board assignment rows as plain dicts for :mod:`coord.usage_rollup`.
349
+
350
+ This is the thin *fetch* caller the pure aggregator's docstring promises:
351
+ it mirrors :func:`coord.board_service.read_board`'s local-vs-remote
352
+ branch, but returns raw wire-shape ``dict`` rows rather than
353
+ :class:`~coord.models.Assignment` instances — the aggregator consumes the
354
+ daemon ``/board`` ``assignments`` shape directly, including
355
+ ``is_interactive``, which is a real DB/wire column but (deliberately,
356
+ #748/#632 — see :mod:`coord.board_bool_guard`) not an ``Assignment``
357
+ dataclass field, so converting through ``Assignment`` would silently
358
+ drop it.
359
+
360
+ Remote reads the same ``/board`` endpoint ``coord status`` polls. Local
361
+ reads the sqlite DB directly via ``SqliteStore.list_assignments()``
362
+ rather than ``board_projection()["assignments"]`` — a usage rollup wants
363
+ full history, not the retention-capped set ``/board`` serves the TUI.
364
+ """
365
+ from coord.client import fetch_board_payload, resolve_board_service # noqa: PLC0415
366
+
367
+ svc = resolve_board_service(flag_url, flag_token)
368
+ if svc is not None:
369
+ payload = fetch_board_payload(svc, timeout=timeout)
370
+ return list(payload.get("assignments") or [])
371
+
372
+ from coord.dao import SqliteStore # noqa: PLC0415
373
+
374
+ return SqliteStore().list_assignments()
375
+
376
+
377
+ # ── Formatting ────────────────────────────────────────────────────────────────
378
+
379
+
380
+ def _fmt_cost(usd: float) -> str:
381
+ if usd < 0.001:
382
+ return "$0.00"
383
+ if usd < 0.01:
384
+ return f"${usd:.4f}"
385
+ return f"${usd:.2f}"
386
+
387
+
388
+ def _fmt_burn_rate(usd_per_hr: float) -> str:
389
+ if usd_per_hr < 0.01:
390
+ return f"${usd_per_hr:.4f}/hr"
391
+ return f"${usd_per_hr:.2f}/hr"
392
+
393
+
394
+ def format_usage_report(session: SessionUsage, window_label: str | None = None) -> str:
395
+ """Return the full multi-section usage report for ``coord usage``.
396
+
397
+ *window_label* is set when the caller resolved one of ``--today``/
398
+ ``--week``/``--month``/``--since`` and pre-filtered *session*'s
399
+ assignments to that window (#1119 review finding #1) — when given, a
400
+ ``USAGE — window: ...`` header and a ``Σ total`` grand-total footer are
401
+ printed, mirroring the convention :func:`format_usage_by_group`/
402
+ :func:`format_usage_by_time` already use, so the resolved window is
403
+ visible even on the legacy (no ``--by``) view. Left as ``None`` (the
404
+ default — no window flag was given), the report is byte-for-byte
405
+ unchanged from before #1119.
406
+ """
407
+ lines: list[str] = []
408
+
409
+ if window_label is not None:
410
+ lines.append(f"USAGE — window: {window_label}")
411
+ lines.append("")
412
+
413
+ # ── Session header ────────────────────────────────────────────────────
414
+ burn = session.burn_rate_usd_per_hour()
415
+ burn_str = _fmt_burn_rate(burn) if burn is not None else "(no session time)"
416
+ high_flag = " ⚠" if burn is not None and burn >= HIGH_BURN_RATE_USD_PER_HOUR else ""
417
+
418
+ n_done = sum(1 for a in session.assignments if a.status == "done")
419
+ n_running = sum(1 for a in session.assignments if a.status == "running")
420
+ n_failed = sum(1 for a in session.assignments if a.status == "failed")
421
+ counts: list[str] = []
422
+ if n_done:
423
+ counts.append(f"{n_done} done")
424
+ if n_running:
425
+ counts.append(f"{n_running} running")
426
+ if n_failed:
427
+ counts.append(f"{n_failed} failed")
428
+ counts_str = ", ".join(counts) if counts else "0 assignments"
429
+
430
+ total_str = _fmt_cost(session.total_cost_usd)
431
+ lines.append(f"Session usage: {total_str} • {counts_str} • burn rate: {burn_str}{high_flag}")
432
+
433
+ # Budget remaining estimate: max(0, 5hr - elapsed) * burn_rate
434
+ elapsed_hours = session.elapsed_hours
435
+ if elapsed_hours is not None and burn is not None and burn > 0:
436
+ budget_remaining_usd = max(0.0, 5.0 - elapsed_hours) * burn
437
+ lines.append(
438
+ f"Est. 5hr budget remaining: ~{_fmt_cost(budget_remaining_usd)} (based on current rate)"
439
+ )
440
+
441
+ lines.append("")
442
+
443
+ # ── Per-assignment table ──────────────────────────────────────────────
444
+ if not session.assignments:
445
+ lines.append("No assignments found.")
446
+ if window_label is not None:
447
+ lines.append("")
448
+ lines.append(f"Σ total {total_str} • {counts_str}")
449
+ return "\n".join(lines)
450
+
451
+ lines.append("Per-assignment:")
452
+ col_id_w = max(8, max(len(a.assignment_id[:8]) for a in session.assignments))
453
+ col_repo_w = max(8, max(len(a.repo_name) for a in session.assignments))
454
+ col_model_w = max(5, max(len(a.model or "(unknown)") for a in session.assignments))
455
+
456
+ header = (
457
+ f" {'ID':<{col_id_w}} {'STATUS':<7} {'REPO':<{col_repo_w}} "
458
+ f"{'#':>5} {'MODEL':<{col_model_w}} {'TURNS':>5} {'DUR':>7} COST"
459
+ )
460
+ lines.append(header)
461
+ lines.append(" " + "-" * (len(header) - 2))
462
+
463
+ for a in session.assignments:
464
+ aid = (a.assignment_id or "")[:8]
465
+ model = a.model or "(unknown)"
466
+ dur = a.duration_str()
467
+ cost = _fmt_cost(a.total_cost_usd)
468
+ line = (
469
+ f" {aid:<{col_id_w}} {a.status:<7} {a.repo_name:<{col_repo_w}} "
470
+ f"#{a.issue_number:>4} {model:<{col_model_w}} {a.num_turns:>5} "
471
+ f"{dur:>7} {cost}"
472
+ )
473
+ lines.append(line)
474
+
475
+ # ── Token summary (only shown when any tokens were recorded) ──────────
476
+ total_in = session.total_input_tokens
477
+ total_out = session.total_output_tokens
478
+ if total_in or total_out:
479
+ lines.append("")
480
+ lines.append(f"Token totals: {total_in:,} input • {total_out:,} output")
481
+
482
+ # ── Per-model breakdown ───────────────────────────────────────────────
483
+ lines.append("")
484
+ lines.append("Per-model:")
485
+ cost_by = session.cost_by_model()
486
+ count_by = session.count_by_model()
487
+ total = session.total_cost_usd
488
+
489
+ for model_name in sorted(cost_by, key=lambda m: cost_by[m], reverse=True):
490
+ n = count_by[model_name]
491
+ c = cost_by[model_name]
492
+ pct = f" ({100 * c / total:.0f}%)" if total > 0 else ""
493
+ noun = "assignment" if n == 1 else "assignments"
494
+ lines.append(f" {model_name:<{col_model_w}} {n} {noun:<12} {_fmt_cost(c)}{pct}")
495
+
496
+ if window_label is not None:
497
+ lines.append("")
498
+ lines.append(f"Σ total {total_str} • {counts_str}")
499
+
500
+ return "\n".join(lines)
501
+
502
+
503
+ # ── Per-issue rollup rendering (#1115 CLI-1) ─────────────────────────────────
504
+ #
505
+ # Consumes coord.usage_rollup.aggregate() (#1118 Core) — this module only
506
+ # renders the plain dict it returns. Distinct 4-decimal cost formatting and
507
+ # compact token/duration formatting are used here (vs. the 2-decimal
508
+ # _fmt_cost/duration_str above) to match the sealed contract mocks exactly
509
+ # (tests/acceptance/ms-37/contract.md, Mocks 1 & 2).
510
+
511
+
512
+ def pricing_dict_from_config(pricing) -> dict:
513
+ """Convert a :class:`~coord.config.PricingConfig` to the plain
514
+ ``{model: {"input": ..., "output": ..., "cache_read": ..., "cache_creation": ...}}``
515
+ dict :func:`coord.usage_rollup.aggregate` expects."""
516
+ return {
517
+ model: {
518
+ "input": rates.input,
519
+ "output": rates.output,
520
+ "cache_read": rates.cache_read,
521
+ "cache_creation": rates.cache_creation,
522
+ }
523
+ for model, rates in pricing.models.items()
524
+ }
525
+
526
+
527
+ def _fmt_cost4(usd: float) -> str:
528
+ """Captured-cost formatting for the rollup views: always 4 decimals."""
529
+ return f"${usd:.4f}"
530
+
531
+
532
+ def _fmt_est4(usd: float) -> str:
533
+ """Estimated-cost formatting: ``~$`` prefix, always visually distinct
534
+ from a captured figure (see :func:`_fmt_cost4`)."""
535
+ return f"~${usd:.4f}"
536
+
537
+
538
+ def _fmt_tokens_compact(n: int) -> str:
539
+ """Compact token count: ``k`` below 1M, one-decimal ``M`` at/above."""
540
+ if n >= 1_000_000:
541
+ return f"{n / 1_000_000:.1f}M"
542
+ if n >= 1_000:
543
+ return f"{round(n / 1_000)}k"
544
+ return str(n)
545
+
546
+
547
+ def _fmt_duration_hms(secs: float | None, *, is_open: bool) -> str:
548
+ """``NmSSs`` duration (e.g. ``20m00s``); ``—`` for an open/unknown leg."""
549
+ if is_open or secs is None:
550
+ return "—"
551
+ total = int(round(secs))
552
+ m, s = divmod(total, 60)
553
+ return f"{m}m{s:02d}s"
554
+
555
+
556
+ def format_usage_by_issue(result: dict, window_label: str) -> str:
557
+ """Render the ``coord usage --by-issue`` view (contract Mock 1) from an
558
+ :func:`coord.usage_rollup.aggregate` result (``by="issue"``).
559
+
560
+ *window_label* is the resolved :class:`~coord.usage_rollup.Window`'s
561
+ ``label`` (e.g. ``"today"``) — printed in the header, not consumed by
562
+ the aggregator itself.
563
+ """
564
+ lines = [f"USAGE — by issue — window: {window_label}"]
565
+ lines.append(
566
+ f"{'issue':<8}{'repo':<10}{'legs':>4} {'cost':<10} {'est(~)':<11} "
567
+ f"{'out / cache':<20}{'time':<10}note"
568
+ )
569
+ for g in result["groups"]:
570
+ issue_no = g["key"]
571
+ repo = g["rows"][0].get("repo_name", "") if g["rows"] else ""
572
+ cost_str = _fmt_cost4(g["cost_captured"]) if g["cost_captured"] > 0 else "—"
573
+ est_str = _fmt_est4(g["cost_est"]) if g["cost_est"] > 0 else "—"
574
+ out_str = _fmt_tokens_compact(g["tokens"]["output"])
575
+ cache_str = _fmt_tokens_compact(g["tokens"]["cache_read"])
576
+ tok_str = f"{out_str} / {cache_str}"
577
+ dur_str = _fmt_duration_hms(g["duration_secs"], is_open=False)
578
+ note = f"⚠ unknown-model:{g['unknown_models']}" if g["unknown_models"] else ""
579
+ lines.append(
580
+ f"#{issue_no:<7}{repo:<10}{g['legs']:>4} {cost_str:<10} {est_str:<11} "
581
+ f"{tok_str:<20}{dur_str:<10}{note}"
582
+ )
583
+
584
+ lines.append("─" * 80)
585
+ t = result["totals"]
586
+ total_out = _fmt_tokens_compact(t["tokens"]["output"])
587
+ total_cache = _fmt_tokens_compact(t["tokens"]["cache_read"])
588
+ total_dur = _fmt_duration_hms(t["duration_secs"], is_open=False)
589
+ progress = f" · {t['open_legs']} in progress" if t["open_legs"] else ""
590
+ lines.append(
591
+ f"Σ captured {_fmt_cost4(t['cost_captured'])} · est {_fmt_est4(t['cost_est'])} · "
592
+ f"total {_fmt_cost4(t['cost_total'])} · {total_out} out / {total_cache} cache · "
593
+ f"{total_dur}{progress}"
594
+ )
595
+ return "\n".join(lines)
596
+
597
+
598
+ def format_usage_issue_drill(rows: list[dict], issue_number: int, pricing) -> str:
599
+ """Render the ``coord usage --issue N`` per-stage drill (contract Mock 2).
600
+
601
+ *rows* are the raw board-row dicts for this one issue (any window
602
+ filtering already applied by the caller); *pricing* is a
603
+ :class:`~coord.config.PricingConfig`. Rows are rendered oldest-first by
604
+ ``dispatched_at`` (falling back to ``finished_at``).
605
+ """
606
+ from coord.usage_rollup import leg_cost, leg_duration, parse_timestamp
607
+
608
+ if not rows:
609
+ return f"No usage data for issue #{issue_number}."
610
+
611
+ def _sort_ts(row: dict) -> float:
612
+ ts = parse_timestamp(row.get("dispatched_at"))
613
+ if ts is None:
614
+ ts = parse_timestamp(row.get("finished_at"))
615
+ return ts if ts is not None else float("inf")
616
+
617
+ ordered = sorted(rows, key=_sort_ts)
618
+ repo = rows[0].get("repo_name", "")
619
+
620
+ total_captured = 0.0
621
+ total_est = 0.0
622
+ for row in rows:
623
+ captured, est, _unknown = leg_cost(row, pricing)
624
+ total_captured += captured
625
+ total_est += est
626
+
627
+ lines = [
628
+ f"#{issue_number} {repo} {_fmt_cost4(total_captured)} captured + "
629
+ f"{_fmt_est4(total_est)} est"
630
+ ]
631
+ lines.append(
632
+ f"{'stage':<9}{'model':<11}{'int':<5}{'cost':<11}{'est(~)':<11}"
633
+ f"{'out':<7}{'cache':<8}{'time':<10}status"
634
+ )
635
+ for row in ordered:
636
+ captured, est, unknown_model = leg_cost(row, pricing)
637
+ duration, is_open = leg_duration(row)
638
+ stage = str(row.get("type") or "")
639
+ model = str(row.get("model") or "(unknown)")
640
+ interactive = "I" if row.get("is_interactive") else "-"
641
+ cost_col = _fmt_cost4(captured) if captured > 0 else "—"
642
+ if est > 0:
643
+ est_col = _fmt_est4(est)
644
+ elif unknown_model:
645
+ est_col = "n/a*"
646
+ else:
647
+ est_col = "—"
648
+ out_col = _fmt_tokens_compact(int(row.get("output_tokens") or 0))
649
+ cache_col = _fmt_tokens_compact(int(row.get("cache_read_tokens") or 0))
650
+ time_col = _fmt_duration_hms(duration, is_open=is_open)
651
+ status = str(row.get("status") or "")
652
+ note = " *unknown model" if unknown_model else ""
653
+ lines.append(
654
+ f"{stage:<9}{model:<11}{interactive:<5}{cost_col:<11}{est_col:<11}"
655
+ f"{out_col:<7}{cache_col:<8}{time_col:<10}{status}{note}"
656
+ )
657
+ return "\n".join(lines)
658
+
659
+
660
+ # ── Cross-repo + time-bucketed rollup rendering (#1119 CLI-2) ────────────────
661
+ #
662
+ # Consumes coord.usage_rollup.aggregate() exactly like the #1115 renderers
663
+ # above — same ``~$``/4-decimal conventions, same plain-dict input. Two new
664
+ # views: a cross-cut rollup by repo/week/month (contract Mock 3) and a
665
+ # time-spent ranking by stage-type or issue (contract Mock 4).
666
+
667
+
668
+ def format_usage_by_group(result: dict, window_label: str, dim: str) -> str:
669
+ """Render ``coord usage --by repo|week|month`` (contract Mock 3 for
670
+ ``dim="repo"``) from an :func:`coord.usage_rollup.aggregate` result.
671
+
672
+ Each row is one group (a repo name, or a week/month bucket string) with
673
+ its issue count (distinct ``issue_number`` across the group's rows),
674
+ legs, captured/estimated/total cost, tokens, and duration. Groups are
675
+ rendered in *result*'s given order — callers sort beforehand (see
676
+ ``coord/commands/status.py``'s ``_usage_sort_key``).
677
+ """
678
+ lines = [f"USAGE — by {dim} — window: {window_label}"]
679
+ lines.append(
680
+ f"{dim:<8}{'issues':>7}{'legs':>6} {'cost':<10} {'est(~)':<11} "
681
+ f"{'total':<11} {'out / cache':<20}time"
682
+ )
683
+ for g in result["groups"]:
684
+ key_str = str(g["key"])
685
+ issues = len({row.get("issue_number") for row in g["rows"]})
686
+ cost_str = _fmt_cost4(g["cost_captured"]) if g["cost_captured"] > 0 else "—"
687
+ est_str = _fmt_est4(g["cost_est"]) if g["cost_est"] > 0 else "—"
688
+ total_str = _fmt_cost4(g["cost_total"])
689
+ out_str = _fmt_tokens_compact(g["tokens"]["output"])
690
+ cache_str = _fmt_tokens_compact(g["tokens"]["cache_read"])
691
+ tok_str = f"{out_str} / {cache_str}"
692
+ dur_str = _fmt_duration_hms(g["duration_secs"], is_open=False)
693
+ lines.append(
694
+ f"{key_str:<8}{issues:>7}{g['legs']:>6} {cost_str:<10} {est_str:<11} "
695
+ f"{total_str:<11} {tok_str:<20}{dur_str}"
696
+ )
697
+
698
+ lines.append("─" * 80)
699
+ t = result["totals"]
700
+ total_out = _fmt_tokens_compact(t["tokens"]["output"])
701
+ total_cache = _fmt_tokens_compact(t["tokens"]["cache_read"])
702
+ total_dur = _fmt_duration_hms(t["duration_secs"], is_open=False)
703
+ progress = f" · {t['open_legs']} in progress" if t["open_legs"] else ""
704
+ lines.append(
705
+ f"Σ total {_fmt_cost4(t['cost_total'])} · {total_out} out / {total_cache} cache · "
706
+ f"{total_dur}{progress}"
707
+ )
708
+ return "\n".join(lines)
709
+
710
+
711
+ def format_usage_by_time(result: dict, window_label: str, dim: str) -> str:
712
+ """Render ``coord usage --by-time`` (contract Mock 4) from an
713
+ :func:`coord.usage_rollup.aggregate` result with ``by="stage"`` (default,
714
+ ``dim="stage"``) or ``by="issue"`` (``dim="issue"``, via ``--by issue``).
715
+
716
+ Ranks groups by share of total in-window active duration — "where is
717
+ wall-clock going." An open leg (no ``finished_at``) contributes 0
718
+ duration but is called out via the group's ``(N in progress)`` note.
719
+ """
720
+ lines = [f"USAGE — time by {dim} — window: {window_label}"]
721
+ key_header = "issue" if dim == "issue" else "stage"
722
+ lines.append(f"{key_header:<14}{'legs':>4} {'time':<10}share")
723
+
724
+ total_dur = result["totals"]["duration_secs"]
725
+ for g in result["groups"]:
726
+ key_str = f"#{g['key']}" if dim == "issue" else str(g["key"])
727
+ dur_str = _fmt_duration_hms(g["duration_secs"], is_open=False)
728
+ pct = f"{(g['duration_secs'] / total_dur * 100):.1f}%" if total_dur > 0 else "—"
729
+ note = f" ({g['open_legs']} in progress)" if g["open_legs"] else ""
730
+ lines.append(f"{key_str:<14}{g['legs']:>4} {dur_str:<10}{pct}{note}")
731
+
732
+ lines.append(f"── total active {_fmt_duration_hms(total_dur, is_open=False)} ──")
733
+ if dim == "stage":
734
+ lines.append("(also available: --by-time --by issue → per-issue duration ranking)")
735
+ else:
736
+ lines.append("(also available: --by-time → time by stage)")
737
+ return "\n".join(lines)
738
+
739
+
740
+ def format_burn_rate_line(session: SessionUsage) -> str | None:
741
+ """One-line burn-rate summary for ``coord status``.
742
+
743
+ Returns ``None`` when the burn rate is below the high threshold or
744
+ can't be computed (no session time).
745
+ """
746
+ burn = session.burn_rate_usd_per_hour()
747
+ if burn is None or burn < HIGH_BURN_RATE_USD_PER_HOUR:
748
+ return None
749
+ total_str = _fmt_cost(session.total_cost_usd)
750
+ burn_str = _fmt_burn_rate(burn)
751
+ n = len(session.assignments)
752
+ noun = "assignment" if n == 1 else "assignments"
753
+ return f"Usage: {total_str} this session • burn rate: {burn_str} ⚠ ({n} {noun})"