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
@@ -0,0 +1,192 @@
1
+ """graphify graph freshness per checkout (#1628).
2
+
3
+ **This is a wrapper, not a reimplementation.** ``coord.graph_health``
4
+ already knows how to answer both halves — :func:`~coord.graph_health.graph_status`
5
+ compares ``GRAPH_REPORT.md``'s "Built from commit" against HEAD (with the
6
+ ``manifest.json``-mtime escape hatch that stops a genuinely-current graph
7
+ from reporting STALE forever), and
8
+ :func:`~coord.graph_health.hooks_path_status` checks the ``core.hooksPath``
9
+ setting that decides whether anything will ever rebuild it. ``coord
10
+ diagnose --graph`` renders them today. Forking either would guarantee the
11
+ two surfaces drift apart, which is the exact failure this milestone's
12
+ "renderers must never re-derive severity" rule exists to prevent — so this
13
+ module calls them and maps their output to a severity.
14
+
15
+ **Why hooks-disabled makes a stale graph CRIT rather than WARN.** A stale
16
+ graph with working hooks is a nuisance: the next commit rebuilds it. A
17
+ stale graph on a checkout with ``core.hooksPath`` unset (or with hooks
18
+ orphaned into ``.git/hooks``, which git ignores entirely once
19
+ ``core.hooksPath`` is set) **will not self-heal at any point in the future**
20
+ — it stays wrong until a human runs ``graphify update`` by hand, and every
21
+ agent that queries it in the meantime gets answers about a commit that is no
22
+ longer HEAD. That is the 2026-07-30 vimcode incident: 128.8h stale, hooks
23
+ disabled, and nothing in the fleet said so. Age escalates a
24
+ hooks-working checkout from WARN to CRIT at ``graph_stale_crit_hours``;
25
+ hooks-disabled skips straight to CRIT because time cannot fix it.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import subprocess
31
+
32
+ from coord.health.models import CheckResult, HealthContext, Severity
33
+ from coord.health.registry import check
34
+ from coord.health.units import human_hours
35
+
36
+
37
+ def _commits_behind(repo_path: str, built_sha: str, head_sha: str) -> int | None:
38
+ """How many commits separate ``built_sha`` from ``head_sha``.
39
+
40
+ Purely informational — an operator (and #1728's H-6 successor, deciding
41
+ how urgently to rebuild) reads better from "13 commits behind" than from
42
+ an age in hours, which conflates "how stale" with "how busy was the
43
+ repo". This does NOT feed severity: :func:`~coord.graph_health.graph_status`
44
+ already decided ``stale`` from the SHA comparison plus the
45
+ manifest-mtime escape hatch, and duplicating that decision here from a
46
+ commit count would be exactly the second copy this module's docstring
47
+ warns against. Best-effort: returns ``None`` (never raises) when the
48
+ shas are abbreviated past what git can resolve, the repo can't be read,
49
+ or the call times out.
50
+ """
51
+ try:
52
+ r = subprocess.run(
53
+ ["git", "-C", str(repo_path), "rev-list", "--count", f"{built_sha}..{head_sha}"],
54
+ capture_output=True,
55
+ text=True,
56
+ timeout=10.0,
57
+ )
58
+ except (subprocess.SubprocessError, OSError):
59
+ return None
60
+ if r.returncode != 0:
61
+ return None
62
+ try:
63
+ return int(r.stdout.strip())
64
+ except ValueError:
65
+ return None
66
+
67
+
68
+ @check(
69
+ id="graph",
70
+ scope="checkout",
71
+ title="graph",
72
+ order=70,
73
+ description="graphify graph freshness vs HEAD, and whether hooks can heal it.",
74
+ )
75
+ def probe_graph(ctx: HealthContext) -> list[CheckResult]:
76
+ from coord.graph_health import graph_status, hooks_path_status # noqa: PLC0415
77
+
78
+ th = ctx.thresholds
79
+ results: list[CheckResult] = []
80
+
81
+ for checkout in ctx.checkouts:
82
+ status = graph_status(checkout.path)
83
+ hooks_ok, hooks_detail = hooks_path_status(checkout.path)
84
+
85
+ values = {
86
+ "path": str(checkout.path),
87
+ "present": status.present,
88
+ "in_sync": status.in_sync,
89
+ "stale": status.stale,
90
+ "stamp_behind": status.stamp_behind,
91
+ "verified_current": status.verified_current,
92
+ "built_sha": status.built_sha,
93
+ "head_sha": status.head_sha,
94
+ "age_seconds": status.age_seconds,
95
+ "age_hours": (
96
+ round(status.age_seconds / 3600.0, 1) if status.age_seconds is not None else None
97
+ ),
98
+ "is_symlink": status.is_symlink,
99
+ "hooks_ok": hooks_ok,
100
+ "hooks_detail": hooks_detail,
101
+ "unknown_reason": status.unknown_reason,
102
+ "warn_hours": th.graph_stale_warn_hours,
103
+ "crit_hours": th.graph_stale_crit_hours,
104
+ }
105
+
106
+ if not status.present:
107
+ # No graph at all. Agents are told to query it first (CLAUDE.md),
108
+ # so its absence silently downgrades every one of them to grep.
109
+ results.append(
110
+ CheckResult(
111
+ check_id="graph",
112
+ scope="checkout",
113
+ subject=checkout.name,
114
+ severity=Severity.WARN,
115
+ headroom="no graph built here",
116
+ detail=status.unknown_reason or "",
117
+ threshold="warn when absent",
118
+ values=values,
119
+ )
120
+ )
121
+ continue
122
+
123
+ if status.unknown_reason and not status.stamp_behind:
124
+ results.append(
125
+ CheckResult(
126
+ check_id="graph",
127
+ scope="checkout",
128
+ subject=checkout.name,
129
+ severity=Severity.UNKNOWN,
130
+ headroom=f"freshness unknown — {status.unknown_reason}",
131
+ error=status.unknown_reason,
132
+ values=values,
133
+ )
134
+ )
135
+ continue
136
+
137
+ age_hours = (status.age_seconds or 0.0) / 3600.0
138
+ age_text = human_hours(status.age_seconds) if status.age_seconds is not None else "?h"
139
+
140
+ # #1728: how many commits separate the stamp from HEAD. Purely
141
+ # additive to the message — severity above is still `status.stale`
142
+ # (SHA comparison + the manifest-mtime escape hatch), never this
143
+ # count, so a repo that made 1 commit vs. 100 since the graph was
144
+ # built is still judged identically on "is it stale", only described
145
+ # differently.
146
+ commits_behind: int | None = None
147
+ if status.stale and status.built_sha and status.head_sha:
148
+ commits_behind = _commits_behind(checkout.path, status.built_sha, status.head_sha)
149
+ values["commits_behind"] = commits_behind
150
+ commits_suffix = (
151
+ ""
152
+ if commits_behind is None
153
+ else f", {commits_behind} commit{'' if commits_behind == 1 else 's'} behind"
154
+ )
155
+
156
+ if not status.stale:
157
+ severity = Severity.OK
158
+ headroom = f"in sync ({(status.built_sha or '')[:8]}), {age_text} old"
159
+ if status.verified_current and status.stamp_behind:
160
+ headroom = f"content current (stamp {(status.built_sha or '')[:8]}), {age_text} old"
161
+ elif not hooks_ok:
162
+ severity = Severity.CRIT
163
+ headroom = f"{age_text} stale{commits_suffix}, hooks disabled -> will not self-heal"
164
+ elif age_hours >= th.graph_stale_crit_hours:
165
+ severity = Severity.CRIT
166
+ headroom = f"{age_text} stale{commits_suffix} (HEAD {(status.head_sha or '')[:8]})"
167
+ elif age_hours >= th.graph_stale_warn_hours:
168
+ severity = Severity.WARN
169
+ headroom = f"{age_text} stale{commits_suffix} (HEAD {(status.head_sha or '')[:8]})"
170
+ else:
171
+ severity = Severity.WARN
172
+ headroom = f"stale, {age_text} old{commits_suffix} (HEAD {(status.head_sha or '')[:8]})"
173
+
174
+ detail = ""
175
+ if severity is not Severity.OK:
176
+ detail = f"fix: graphify update {checkout.path}"
177
+ if not hooks_ok:
178
+ detail = f"{detail} — {hooks_detail}"
179
+
180
+ results.append(
181
+ CheckResult(
182
+ check_id="graph",
183
+ scope="checkout",
184
+ subject=checkout.name,
185
+ severity=severity,
186
+ headroom=headroom,
187
+ detail=detail,
188
+ threshold=f"crit at {th.graph_stale_crit_hours:.0f}h (or any age with hooks off)",
189
+ values=values,
190
+ )
191
+ )
192
+ return results
@@ -0,0 +1,88 @@
1
+ """Max-plan 5h/weekly window headroom (#1628).
2
+
3
+ Another wrapper, not a rewrite: ``coord.usage_limits`` already probes this
4
+ (``claude -p "/usage" --output-format json``, ~700ms, $0, 0 turns) and
5
+ already caches it for 60s because the endpoint is itself rate-limited and
6
+ Claude Code serves the bars up to an hour stale anyway. Going through
7
+ :func:`~coord.usage_limits.get_plan_limits` means a ``coord health`` run
8
+ next to a ``coord drive`` preflight shares one probe rather than racing it
9
+ into that rate limit.
10
+
11
+ Marked ``cost="network"``: it shells out to ``claude`` and hits Anthropic's
12
+ API, so it is excluded from the cheap set that has to fit the ~2s registry
13
+ budget, and ``coord health --no-network`` skips it entirely (recording that
14
+ it was skipped — "we didn't look" must never render as "nothing wrong").
15
+
16
+ A probe that returns ``status="unknown"`` — no OAuth subscription, an API
17
+ key / Bedrock / Vertex auth mode where the windows simply don't apply, a
18
+ timeout — reports UNKNOWN, never OK. Same rule as the dispatch gate: a read
19
+ we can't trust is not a clean bill of health.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from coord.health.models import CheckResult, HealthContext, Severity
25
+ from coord.health.registry import COST_NETWORK, check
26
+
27
+
28
+ @check(
29
+ id="plan_usage",
30
+ scope="machine",
31
+ title="plan usage",
32
+ order=80,
33
+ cost=COST_NETWORK,
34
+ description="Headroom left in the account's Max-plan 5h/weekly windows.",
35
+ )
36
+ def probe_plan_usage(ctx: HealthContext) -> CheckResult:
37
+ from coord.usage_limits import get_plan_limits # noqa: PLC0415
38
+
39
+ th = ctx.thresholds
40
+ limits = get_plan_limits()
41
+
42
+ if not limits.ok:
43
+ return CheckResult(
44
+ check_id="plan_usage",
45
+ scope="machine",
46
+ severity=Severity.UNKNOWN,
47
+ headroom="plan windows unavailable (no subscription probe)",
48
+ error=limits.error or "probe returned unknown",
49
+ values=limits.to_dict(),
50
+ )
51
+
52
+ windows: list[tuple[str, float, str | None]] = []
53
+ if limits.session_pct is not None:
54
+ windows.append(("session", limits.session_pct, limits.session_resets_at))
55
+ if limits.week_pct is not None:
56
+ windows.append(("week", limits.week_pct, limits.week_resets_at))
57
+
58
+ peak = max((pct for _, pct, _ in windows), default=0.0)
59
+ if peak >= th.plan_usage_crit_pct:
60
+ severity = Severity.CRIT
61
+ elif peak >= th.plan_usage_warn_pct:
62
+ severity = Severity.WARN
63
+ else:
64
+ severity = Severity.OK
65
+
66
+ parts = []
67
+ for label, pct, resets in windows:
68
+ reset_note = f" resets {resets}" if resets else ""
69
+ parts.append(f"{label} {pct:.0f}% used{reset_note}")
70
+ headroom = "; ".join(parts) or "no windows reported"
71
+
72
+ values = limits.to_dict()
73
+ values.update(
74
+ {
75
+ "peak_pct": peak,
76
+ "warn_pct": th.plan_usage_warn_pct,
77
+ "crit_pct": th.plan_usage_crit_pct,
78
+ }
79
+ )
80
+
81
+ return CheckResult(
82
+ check_id="plan_usage",
83
+ scope="machine",
84
+ severity=severity,
85
+ headroom=headroom,
86
+ threshold=f"crit at {th.plan_usage_crit_pct:.0f}%",
87
+ values=values,
88
+ )
@@ -0,0 +1,161 @@
1
+ """Per-checkout git hygiene: parked branch, uncommitted changes (#1628).
2
+
3
+ Both of these are the #561/#601 failure mode. A Build, a ``coord test``, or
4
+ an interactive agent inspecting a branch does ``git checkout`` in the *live*
5
+ base checkout and doesn't restore it. From then on the machine builds,
6
+ tests, and (if it's the editable checkout) *runs the coordinator itself*
7
+ from that branch's code, silently — #561 disabled guards that way, #601 ran
8
+ old code against a retired local DB. ``coord/cli.py`` already shouts about
9
+ this for the editable checkout on every command; this generalises it to
10
+ every checkout in ``coordinator.yml`` and puts it in the same report as the
11
+ rest of the fleet's headroom.
12
+
13
+ Uncommitted changes in a base checkout are the sibling signal: the merge
14
+ agent rebases and force-pushes in there, and a dirty tree makes that fail in
15
+ ways that read as a coordinator bug. Both are WARN, never CRIT — they are
16
+ recoverable in one command and a human is often *deliberately* mid-edit.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ from coord.health.models import CheckResult, HealthContext, Severity
25
+ from coord.health.registry import check
26
+
27
+ _GIT_TIMEOUT = 5.0
28
+
29
+
30
+ def _git(repo: Path, *args: str) -> tuple[int, str]:
31
+ """``(returncode, stdout)`` — never raises for the ordinary failures.
32
+
33
+ stdout is returned **unstripped**: ``git status --porcelain`` encodes the
34
+ index/worktree state in the first two columns, so ``" M path"`` and
35
+ ``"M path"`` mean different things and a ``.strip()`` here silently ate
36
+ the leading space (and with it one character of every path).
37
+ """
38
+ try:
39
+ result = subprocess.run(
40
+ ["git", "-C", str(repo), *args],
41
+ capture_output=True,
42
+ text=True,
43
+ timeout=_GIT_TIMEOUT,
44
+ )
45
+ except (OSError, subprocess.SubprocessError) as exc:
46
+ return 1, f"{type(exc).__name__}: {exc}"
47
+ return result.returncode, result.stdout
48
+
49
+
50
+ @check(
51
+ id="repo_branch",
52
+ scope="checkout",
53
+ title="branch",
54
+ order=60,
55
+ description="Base checkouts are on their default (or configured develop) branch.",
56
+ )
57
+ def probe_repo_branch(ctx: HealthContext) -> list[CheckResult]:
58
+ results: list[CheckResult] = []
59
+ for checkout in ctx.checkouts:
60
+ code, raw = _git(checkout.path, "rev-parse", "--abbrev-ref", "HEAD")
61
+ branch = raw.strip()
62
+ if code != 0:
63
+ results.append(
64
+ CheckResult(
65
+ check_id="repo_branch",
66
+ scope="checkout",
67
+ subject=checkout.name,
68
+ severity=Severity.UNKNOWN,
69
+ headroom="could not read HEAD",
70
+ error=branch,
71
+ values={"path": str(checkout.path)},
72
+ )
73
+ )
74
+ continue
75
+
76
+ home = checkout.home_branches
77
+ detached = branch == "HEAD"
78
+ parked = detached or branch not in home
79
+ results.append(
80
+ CheckResult(
81
+ check_id="repo_branch",
82
+ scope="checkout",
83
+ subject=checkout.name,
84
+ severity=Severity.WARN if parked else Severity.OK,
85
+ headroom=("detached HEAD" if detached else branch)
86
+ + (f" (expected {home[0]})" if parked else ""),
87
+ threshold="warn when off the default branch",
88
+ detail=(
89
+ f"restore with: git -C {checkout.path} checkout {home[0]}"
90
+ if parked
91
+ else ""
92
+ ),
93
+ values={
94
+ "path": str(checkout.path),
95
+ "branch": branch,
96
+ "expected": list(home),
97
+ "detached": detached,
98
+ "parked": parked,
99
+ },
100
+ )
101
+ )
102
+ return results
103
+
104
+
105
+ @check(
106
+ id="repo_dirty",
107
+ scope="checkout",
108
+ title="worktree clean",
109
+ order=61,
110
+ description="Base checkouts have no uncommitted changes.",
111
+ )
112
+ def probe_repo_dirty(ctx: HealthContext) -> list[CheckResult]:
113
+ results: list[CheckResult] = []
114
+ for checkout in ctx.checkouts:
115
+ # --porcelain is the stable machine format; --untracked-files=normal
116
+ # (the default) counts a stray build artifact as dirty, which is the
117
+ # behaviour we want — the merge agent's force-push trips on those too.
118
+ code, out = _git(checkout.path, "status", "--porcelain")
119
+ if code != 0:
120
+ results.append(
121
+ CheckResult(
122
+ check_id="repo_dirty",
123
+ scope="checkout",
124
+ subject=checkout.name,
125
+ severity=Severity.UNKNOWN,
126
+ headroom="could not read git status",
127
+ error=out,
128
+ values={"path": str(checkout.path)},
129
+ )
130
+ )
131
+ continue
132
+
133
+ lines = [line for line in out.splitlines() if line.strip()]
134
+ count = len(lines)
135
+ if count:
136
+ plural = "s" if count != 1 else ""
137
+ # Porcelain v1: two status columns, a space, then the path.
138
+ sample = ", ".join(line[3:].strip() for line in lines[:3])
139
+ if count > 3:
140
+ sample += ", …"
141
+ headroom = f"{count} uncommitted change{plural}"
142
+ if sample:
143
+ headroom = f"{headroom} ({sample})"
144
+ else:
145
+ headroom = "clean"
146
+ results.append(
147
+ CheckResult(
148
+ check_id="repo_dirty",
149
+ scope="checkout",
150
+ subject=checkout.name,
151
+ severity=Severity.WARN if count else Severity.OK,
152
+ headroom=headroom,
153
+ threshold="warn when dirty",
154
+ values={
155
+ "path": str(checkout.path),
156
+ "dirty_count": count,
157
+ "entries": lines[:20],
158
+ },
159
+ )
160
+ )
161
+ return results