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_limits.py ADDED
@@ -0,0 +1,358 @@
1
+ """Max-plan 5h/weekly usage-window probe and dispatch gate (#1466).
2
+
3
+ Unattended driving (``coord drive``, ``coord approve``) previously had no
4
+ idea how much of the account's Max-plan session/weekly usage was left, so it
5
+ would happily dispatch straight into a 5-hour or weekly wall — the first
6
+ sign was a worker dying mid-task with the branch stranded (see
7
+ ``coord.worker_events.detect_usage_limit_kill`` for the *mid-flight* half of
8
+ that signal). This module is the *pre-flight* half: probe the plan bars
9
+ before dispatching, so ``coord drive``'s ``preflight()`` and ``coord
10
+ approve`` can warn (or, once trusted, refuse) instead of finding out the
11
+ hard way.
12
+
13
+ THE PROBE. ``claude -p "/usage" --output-format json`` returns the same
14
+ live plan bars the interactive statusline shows, at essentially no cost
15
+ (``total_cost_usd: 0``, ``num_turns: 0``, ~700ms measured). Its ``.result``
16
+ field is prose, not a stable contract, e.g.::
17
+
18
+ Current session: 57% used · resets Jul 27, 1:30am (America/Chicago)
19
+ Current week (all models): 29% used · resets Aug 1, 12pm (America/Chicago)
20
+ Current week (Fable): 0% used
21
+
22
+ :func:`parse_usage_probe_output` parses that defensively — a garbled or
23
+ reworded string yields ``PlanLimits(status="unknown")``, never a raised
24
+ exception, and never something that looks like an "ok" read of the wrong
25
+ numbers.
26
+
27
+ SCOPE OF THE PROBE. The bars are **server-side and account-wide** — one
28
+ probe on the daemon host covers every agent machine sharing that login. (The
29
+ "what's contributing" breakdown underneath ``/usage``'s prose is
30
+ local-machine-only and is not parsed here — it answers a different
31
+ question than "how much is left".) The endpoint is itself rate-limited, and
32
+ Claude Code then serves the bars up to 60 minutes stale, so callers MUST NOT
33
+ poll it per-dispatch — :func:`get_plan_limits` caches for
34
+ :data:`CACHE_TTL_SECS`.
35
+
36
+ AUTH SCOPE. This only means anything under subscription OAuth. Under an API
37
+ key, Bedrock, or Vertex the plan windows do not apply and ``/usage`` returns
38
+ nothing useful — :func:`parse_usage_probe_output` returns ``status="unknown"``
39
+ for that prose too (it doesn't match the session/week bars), so callers
40
+ degrade to skipping the gate automatically, with no separate auth check
41
+ needed here.
42
+
43
+ CAVEAT (leave this comment wherever the gate is wired in — the issue asked
44
+ for it explicitly). Anthropic announced that ``claude -p`` / Agent SDK usage
45
+ moves *off* the subscription windows onto a separate monthly credit pool
46
+ ($100 Max 5x / $200 Max 20x). That rollout is **paused as of 2026-06-15** —
47
+ the help article currently states nothing has changed, so today's headless
48
+ workers still draw the same pool ``/usage`` reports and this gate is
49
+ predictive of a worker running into it. If that rollout resumes, the 5h/
50
+ weekly bars stop predicting worker blocks and the gate would need to track
51
+ credit balance instead.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import json
57
+ import re
58
+ import subprocess
59
+ import time
60
+ from dataclasses import dataclass, field
61
+ from typing import TYPE_CHECKING
62
+
63
+ # Reuse the #859 absolute-path resolution (systemd --user strips PATH) rather
64
+ # than re-deriving it — see coord.test_orchestrator.resolve_claude_bin's
65
+ # docstring for why a bare "claude" silently breaks under coord-serve.
66
+ from coord.test_orchestrator import resolve_claude_bin
67
+
68
+ if TYPE_CHECKING:
69
+ from coord.config import UsageGateConfig
70
+
71
+ # How long a cached probe stays valid. The `/usage` endpoint is itself
72
+ # rate-limited and Claude Code serves bars up to 60 minutes stale anyway, so
73
+ # there is no benefit to probing more often than this — only more risk of
74
+ # tripping that same rate limit ourselves.
75
+ CACHE_TTL_SECS = 60.0
76
+
77
+
78
+ # ── data classes ─────────────────────────────────────────────────────────────
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class ModelWeekUsage:
83
+ """One ``Current week (<model>): NN% used`` row (per-model breakdown)."""
84
+
85
+ label: str
86
+ used_pct: float
87
+ resets_at: str | None = None
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class PlanLimits:
92
+ """Parsed ``/usage`` bars, or the reason we don't have them.
93
+
94
+ ``status`` is the load-bearing field: ``"ok"`` means *both* percentages
95
+ were parsed from a probe that actually ran; anything else is
96
+ ``"unknown"`` and callers must treat it as "no information", never as
97
+ "usage is fine" — a probe failure silently degrading a gate to "always
98
+ proceed" is fine (that's the intended fail-open behaviour); a probe
99
+ failure being mistaken for "0% used" is not.
100
+ """
101
+
102
+ status: str # "ok" | "unknown"
103
+ session_pct: float | None = None
104
+ session_resets_at: str | None = None
105
+ week_pct: float | None = None
106
+ week_resets_at: str | None = None
107
+ week_by_model: tuple[ModelWeekUsage, ...] = ()
108
+ error: str | None = None
109
+ raw: str = ""
110
+
111
+ @property
112
+ def ok(self) -> bool:
113
+ return self.status == "ok"
114
+
115
+ def to_dict(self) -> dict:
116
+ return {
117
+ "status": self.status,
118
+ "session_pct": self.session_pct,
119
+ "session_resets_at": self.session_resets_at,
120
+ "week_pct": self.week_pct,
121
+ "week_resets_at": self.week_resets_at,
122
+ "week_by_model": [
123
+ {"label": m.label, "used_pct": m.used_pct, "resets_at": m.resets_at}
124
+ for m in self.week_by_model
125
+ ],
126
+ "error": self.error,
127
+ }
128
+
129
+
130
+ _UNKNOWN = PlanLimits(status="unknown")
131
+
132
+
133
+ # ── parsing (defensive — `.result` is prose, not a contract) ────────────────
134
+
135
+ _PCT = r"(\d+(?:\.\d+)?)\s*%\s*used"
136
+ _RESETS = r"(?:\s*[·•]\s*resets\s+(.+))?"
137
+
138
+ _SESSION_RE = re.compile(
139
+ r"Current\s+session:\s*" + _PCT + _RESETS, re.IGNORECASE
140
+ )
141
+ _WEEK_LINE_RE = re.compile(
142
+ r"Current\s+week\s*\(([^)]*)\):\s*" + _PCT + _RESETS, re.IGNORECASE
143
+ )
144
+
145
+
146
+ def parse_usage_probe_output(text: str) -> PlanLimits:
147
+ """Parse ``claude -p "/usage"``'s ``.result`` prose into a
148
+ :class:`PlanLimits`.
149
+
150
+ Never raises — a string that doesn't contain a recognisable "Current
151
+ session" or "Current week" bar (wrong CLI version, changed wording,
152
+ empty, garbage) yields ``PlanLimits(status="unknown", ...)`` with the
153
+ original text attached for diagnostics.
154
+ """
155
+ if not isinstance(text, str) or not text.strip():
156
+ return PlanLimits(status="unknown", error="empty /usage output", raw=text or "")
157
+
158
+ session_pct: float | None = None
159
+ session_resets: str | None = None
160
+ week_pct: float | None = None
161
+ week_resets: str | None = None
162
+ week_by_model: list[ModelWeekUsage] = []
163
+
164
+ m = _SESSION_RE.search(text)
165
+ if m:
166
+ try:
167
+ session_pct = float(m.group(1))
168
+ except ValueError:
169
+ session_pct = None
170
+ resets = m.group(2)
171
+ session_resets = resets.strip() if resets else None
172
+
173
+ for m in _WEEK_LINE_RE.finditer(text):
174
+ label = (m.group(1) or "").strip()
175
+ try:
176
+ pct = float(m.group(2))
177
+ except ValueError:
178
+ continue
179
+ resets = m.group(3)
180
+ resets = resets.strip() if resets else None
181
+ if label.lower() == "all models":
182
+ week_pct = pct
183
+ week_resets = resets
184
+ elif label:
185
+ week_by_model.append(ModelWeekUsage(label=label, used_pct=pct, resets_at=resets))
186
+
187
+ if session_pct is None and week_pct is None:
188
+ return PlanLimits(
189
+ status="unknown",
190
+ error="no recognisable usage bars in /usage output",
191
+ raw=text,
192
+ )
193
+
194
+ return PlanLimits(
195
+ status="ok",
196
+ session_pct=session_pct,
197
+ session_resets_at=session_resets,
198
+ week_pct=week_pct,
199
+ week_resets_at=week_resets,
200
+ week_by_model=tuple(week_by_model),
201
+ raw=text,
202
+ )
203
+
204
+
205
+ # ── the probe (I/O) ──────────────────────────────────────────────────────────
206
+
207
+
208
+ def probe_plan_limits(*, timeout: float = 15.0) -> PlanLimits:
209
+ """Run ``claude -p "/usage" --output-format json`` and parse the result.
210
+
211
+ Never raises: a missing binary, a non-OAuth auth mode (API key / Bedrock
212
+ / Vertex — the plan windows simply don't apply there), a timeout, or
213
+ malformed JSON all come back as ``PlanLimits(status="unknown")`` rather
214
+ than propagating, so a probe failure can never fail a dispatch by
215
+ accident. Costs one ``claude -p`` round trip (~700ms, $0, 0 turns,
216
+ verified against the real CLI) — callers that don't want that cost on
217
+ every call should go through :func:`get_plan_limits` instead.
218
+ """
219
+ cmd = [resolve_claude_bin(), "-p", "/usage", "--output-format", "json"]
220
+ try:
221
+ result = subprocess.run(
222
+ cmd,
223
+ input="",
224
+ capture_output=True,
225
+ text=True,
226
+ timeout=timeout,
227
+ )
228
+ except (OSError, subprocess.TimeoutExpired) as exc:
229
+ return PlanLimits(status="unknown", error=f"{type(exc).__name__}: {exc}")
230
+
231
+ if result.returncode != 0:
232
+ return PlanLimits(
233
+ status="unknown",
234
+ error=f"claude -p /usage exited {result.returncode}: "
235
+ f"{(result.stderr or '').strip()[:300]}",
236
+ )
237
+
238
+ try:
239
+ outer = json.loads(result.stdout)
240
+ except (json.JSONDecodeError, TypeError):
241
+ return PlanLimits(status="unknown", error="non-JSON output from claude -p /usage")
242
+
243
+ if not isinstance(outer, dict):
244
+ return PlanLimits(status="unknown", error="unexpected JSON shape from claude -p /usage")
245
+
246
+ text = outer.get("result")
247
+ if not isinstance(text, str):
248
+ return PlanLimits(status="unknown", error="no .result field in claude -p /usage output")
249
+
250
+ return parse_usage_probe_output(text)
251
+
252
+
253
+ @dataclass
254
+ class _Cache:
255
+ at: float = 0.0
256
+ limits: PlanLimits = field(default_factory=lambda: _UNKNOWN)
257
+
258
+
259
+ _cache = _Cache()
260
+
261
+
262
+ def get_plan_limits(*, force: bool = False, timeout: float = 15.0) -> PlanLimits:
263
+ """Cached wrapper around :func:`probe_plan_limits` — the entry point
264
+ dispatch gates should use.
265
+
266
+ Cached for :data:`CACHE_TTL_SECS`: the endpoint is itself rate-limited
267
+ and Claude Code already serves stale (up to 60m) bars, so a ``coord
268
+ drive`` poll loop or a ``coord approve`` batch of several proposals must
269
+ share one probe rather than shelling out to ``claude`` per call.
270
+ """
271
+ now = time.monotonic()
272
+ if not force and (now - _cache.at) < CACHE_TTL_SECS:
273
+ return _cache.limits
274
+ limits = probe_plan_limits(timeout=timeout)
275
+ _cache.at = now
276
+ _cache.limits = limits
277
+ return limits
278
+
279
+
280
+ def reset_cache() -> None:
281
+ """Test/CLI-only escape hatch — drop the cached probe."""
282
+ _cache.at = 0.0
283
+ _cache.limits = _UNKNOWN
284
+
285
+
286
+ # ── the gate (pure) ──────────────────────────────────────────────────────────
287
+
288
+
289
+ @dataclass(frozen=True)
290
+ class UsageGateResult:
291
+ """What :func:`evaluate_usage_gate` decided, and why."""
292
+
293
+ action: str # "proceed" | "warn" | "block"
294
+ message: str = ""
295
+
296
+ @property
297
+ def blocks(self) -> bool:
298
+ return self.action == "block"
299
+
300
+
301
+ def _format_trigger(label: str, pct: float, resets_at: str | None) -> str:
302
+ reset_note = f", resets {resets_at}" if resets_at else ""
303
+ return f"{label} {pct:.0f}% used{reset_note}"
304
+
305
+
306
+ def evaluate_usage_gate(limits: PlanLimits, gate_cfg: "UsageGateConfig") -> UsageGateResult:
307
+ """Pure decision: given a probed :class:`PlanLimits` and a
308
+ ``coord.config.UsageGateConfig``, what should dispatch do?
309
+
310
+ - ``gate_cfg.mode == "disabled"`` (the on/off switch) → always proceed, no
311
+ message — the gate is not consulted at all.
312
+ - ``limits`` not ``ok`` (probe unavailable/unknown, including non-OAuth
313
+ auth) → always proceed. A probe we can't trust must never block or
314
+ even warn — see the module docstring.
315
+ - Below both thresholds → proceed, no message.
316
+ - At/above a threshold → ``"warn"`` or ``"block"`` per ``gate_cfg.mode``
317
+ (default ``"warn"`` — see ``UsageGateConfig`` for why), with a message
318
+ naming which window(s) tripped and their reset time(s).
319
+ """
320
+ if gate_cfg.mode == "disabled":
321
+ return UsageGateResult("proceed")
322
+ if not limits.ok:
323
+ return UsageGateResult("proceed")
324
+
325
+ triggers: list[str] = []
326
+ if limits.session_pct is not None and limits.session_pct >= gate_cfg.session_threshold_pct:
327
+ triggers.append(_format_trigger("session", limits.session_pct, limits.session_resets_at))
328
+ if limits.week_pct is not None and limits.week_pct >= gate_cfg.week_threshold_pct:
329
+ triggers.append(_format_trigger("week", limits.week_pct, limits.week_resets_at))
330
+
331
+ if not triggers:
332
+ return UsageGateResult("proceed")
333
+
334
+ message = "Max-plan usage near limit: " + "; ".join(triggers)
335
+ action = "block" if gate_cfg.mode == "block" else "warn"
336
+ return UsageGateResult(action, message)
337
+
338
+
339
+ # ── human-readable rendering (``coord usage --limits``) ────────────────────
340
+
341
+
342
+ def format_plan_limits(limits: PlanLimits) -> str:
343
+ """Render *limits* for ``coord usage --limits``."""
344
+ if not limits.ok:
345
+ detail = f": {limits.error}" if limits.error else ""
346
+ return f"Plan limits: unknown (probe unavailable{detail})"
347
+
348
+ lines = ["Plan limits (subscription 5h/weekly windows):"]
349
+ if limits.session_pct is not None:
350
+ reset = f" (resets {limits.session_resets_at})" if limits.session_resets_at else ""
351
+ lines.append(f" session : {limits.session_pct:.0f}% used{reset}")
352
+ if limits.week_pct is not None:
353
+ reset = f" (resets {limits.week_resets_at})" if limits.week_resets_at else ""
354
+ lines.append(f" week : {limits.week_pct:.0f}% used{reset}")
355
+ for m in limits.week_by_model:
356
+ reset = f" (resets {m.resets_at})" if m.resets_at else ""
357
+ lines.append(f" week ({m.label}): {m.used_pct:.0f}% used{reset}")
358
+ return "\n".join(lines)