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/gates.py ADDED
@@ -0,0 +1,582 @@
1
+ """#1657: ``coord gates <repo> <issue>`` — read a work row's gate columns
2
+ plus the LIVE gate decision (review / test / merge), without a hand-extracted
3
+ bearer token and a raw ``/board`` curl.
4
+
5
+ Two things were missing before this module existed:
6
+
7
+ 1. A CLI surface for the raw columns every gate reads — ``test_state``,
8
+ ``smoke_test``, ``test_reason``, ``test_toolchain`` (#1629),
9
+ ``review_state``, ``review_verdict``, ``review_of_assignment_id`` — none
10
+ of which ``coord status`` or ``coord diagnose --stage test`` prints (see
11
+ #1657's "diagnose --stage test" repro: it reports the *assignment row*'s
12
+ status, never ``test_state`` itself, which was ``"running"`` at the
13
+ moment that mattered).
14
+ 2. The gate *decision*, not just the columns — in particular whether a
15
+ recorded verdict is #1479-stale (recorded against a base/branch SHA that
16
+ has since moved), which is otherwise unexplainable from any surface the
17
+ operator has: a verdict can read ``passed`` while ``coord merge`` still
18
+ refuses with ``smoke_required``.
19
+
20
+ :func:`build_gate_report` is the read-only core (board + config + an
21
+ optional ``gh_ops`` duck-typed seam in, a :class:`GateReport` out); it
22
+ reuses ``coord.merge_queue``'s own review/smoke gate functions
23
+ (:func:`~coord.merge_queue.has_approved_review`,
24
+ :func:`~coord.merge_queue.evaluate_smoke_verdict`) rather than
25
+ re-implementing the #1479 freshness math a second time, so this can never
26
+ drift from what ``coord merge``/``coord merge --plan`` actually decide.
27
+
28
+ Read-only by construction: nothing in this module calls ``save_board``,
29
+ ``save_queue``, or any ``gh`` write. The synthetic
30
+ :class:`~coord.merge_queue.QueuedMerge` built in :func:`build_gate_report`
31
+ is never persisted — it exists only to hand the existing gate functions the
32
+ duck-typed shape they expect.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from dataclasses import asdict, dataclass, field
38
+ from typing import TYPE_CHECKING
39
+
40
+ from coord.models import WORK_LIKE_TYPES, effective_issue_number
41
+
42
+ if TYPE_CHECKING: # avoid import cycles / heavy imports at module load
43
+ from coord.config import Config
44
+ from coord.merge_queue import GhOps
45
+ from coord.models import Assignment, Board
46
+
47
+ # Mirrors the MergeEvent.kind tokens merge_queue.process() already emits for
48
+ # these two refusals — same vocabulary, so grepping the daemon log for
49
+ # "smoke_required" finds both a live merge attempt AND a `coord gates` read.
50
+ REVIEW_REQUIRED = "review_required"
51
+ SMOKE_REQUIRED = "smoke_required"
52
+
53
+
54
+ @dataclass
55
+ class AssignmentGateRow:
56
+ """One board row's gate-relevant columns — the raw-dump half of the report."""
57
+
58
+ assignment_id: str | None
59
+ type: str
60
+ status: str | None
61
+ branch: str | None
62
+ machine_name: str | None
63
+ provider_name: str | None
64
+ dispatched_at: float | None
65
+ is_interactive: bool | None
66
+ # #1730: the two-number reality #1553 introduced — `issue_number` is what
67
+ # the row is BOOKED to (the tracking/epic issue for an oracle-loop
68
+ # slice), `for_issue_number` is what it's actually FOR (the child), when
69
+ # set. Surfaced on every row so a query that only matched via
70
+ # `effective_issue_number` (see `build_gate_report`) is legible rather
71
+ # than silently showing rows under the "wrong" issue with no explanation.
72
+ issue_number: int
73
+ for_issue_number: int | None
74
+ test_state: str | None
75
+ smoke_test: str | None
76
+ test_reason: str | None
77
+ # #1629 (H-2): the toolchain that produced test_state, when resolvable.
78
+ # None for pre-1629 rows or an unresolvable toolchain — rendered as
79
+ # "unknown", never as a mismatch.
80
+ test_toolchain: str | None
81
+ review_state: str | None
82
+ review_verdict: str | None
83
+ review_of_assignment_id: str | None
84
+ # #1956: WHO recorded review_verdict and HOW — "agent" (the parsed
85
+ # common case, or None for pre-#1956 rows), "recovered", or
86
+ # "overridden". See coord.models.Assignment.verdict_source.
87
+ verdict_source: str | None
88
+ verdict_source_reason: str | None
89
+
90
+
91
+ @dataclass
92
+ class GateDecision:
93
+ """The live decision for one gate (``"review"`` | ``"test"`` | ``"merge"``)."""
94
+
95
+ gate: str
96
+ required: bool
97
+ ok: bool
98
+ reason: str | None = None
99
+ # #1479 staleness detail — set only when this gate's refusal is a STALE
100
+ # (not MISSING) verdict.
101
+ anchor: str | None = None # "base" | "branch"
102
+ recorded_sha: str | None = None
103
+ current_sha: str | None = None
104
+ # #1956: True only for the "review" gate, and only when it's blocked
105
+ # because a linked review row finished (status="done") with NO
106
+ # parseable verdict at all — a defect, not a state. Distinguishing this
107
+ # from an ordinary "review required but not approved" (a review that
108
+ # simply hasn't run, or genuinely requested changes) matters: it needs
109
+ # OPERATOR RECOVERY (see `reason` for the exact command), not another
110
+ # dispatched review — re-dispatching just re-derives a conclusion that
111
+ # already exists in the log, per #1956's "Not the fix" section.
112
+ verdict_unparseable: bool = False
113
+ # #2024: WHICH assignment supplied this gate's verdict. Populated for the
114
+ # "test" gate, where the merge gate is deliberately branch-scoped (a Test
115
+ # run measures the (branch, base) pair, #1819) and so is routinely
116
+ # satisfied by a row that is NOT the branch's current work row — the
117
+ # parent of a `--fix-of` round, most often. Without naming the row, `coord
118
+ # gates` printing `test : passed` while `coord drive` holds on `test=-`
119
+ # for the fix row reads as two components disagreeing about one fact
120
+ # instead of two components correctly answering two different questions.
121
+ assignment_id: str | None = None
122
+
123
+
124
+ @dataclass
125
+ class GateReport:
126
+ repo_name: str
127
+ issue_number: int
128
+ branch: str | None = None
129
+ target_branch: str | None = None
130
+ rows: list[AssignmentGateRow] = field(default_factory=list)
131
+ decisions: list[GateDecision] = field(default_factory=list)
132
+ notes: list[str] = field(default_factory=list)
133
+
134
+
135
+ def _row_from_assignment(a: "Assignment") -> AssignmentGateRow:
136
+ return AssignmentGateRow(
137
+ assignment_id=a.assignment_id,
138
+ type=a.type or "work",
139
+ status=a.status,
140
+ branch=a.branch,
141
+ machine_name=a.machine_name,
142
+ provider_name=a.provider_name,
143
+ dispatched_at=a.dispatched_at,
144
+ is_interactive=None, # backfilled by _backfill_is_interactive, best-effort
145
+ issue_number=a.issue_number,
146
+ for_issue_number=a.for_issue_number,
147
+ test_state=a.test_state,
148
+ smoke_test=a.smoke_test,
149
+ test_reason=a.test_reason,
150
+ test_toolchain=a.test_toolchain,
151
+ review_state=a.review_state,
152
+ review_verdict=a.review_verdict,
153
+ review_of_assignment_id=a.review_of_assignment_id,
154
+ verdict_source=a.verdict_source,
155
+ verdict_source_reason=a.verdict_source_reason,
156
+ )
157
+
158
+
159
+ def _backfill_is_interactive(rows: list[AssignmentGateRow]) -> None:
160
+ """Populate ``row.is_interactive`` from the ``assignments`` table.
161
+
162
+ #748/#632: ``is_interactive`` is a real DB/wire column deliberately kept
163
+ OFF the ``Assignment`` dataclass (see ``coord.usage.fetch_usage_rows``'s
164
+ docstring) — every board row read via ``Board``/``Assignment`` therefore
165
+ has no way to answer "was this the #555 interactive-review exclusion?"
166
+ without a second, scoped, read-only query. Mutates *rows* in place;
167
+ best-effort — any DB error (e.g. no local DB on a pure thin-client
168
+ process, though ``coord gates`` always runs where the canonical DB
169
+ lives) leaves every ``is_interactive`` at ``None`` rather than raising.
170
+ """
171
+ ids = [r.assignment_id for r in rows if r.assignment_id]
172
+ if not ids:
173
+ return
174
+ try:
175
+ from coord.db import get_connection # noqa: PLC0415
176
+
177
+ conn = get_connection()
178
+ placeholders = ",".join("?" for _ in ids)
179
+ found = conn.execute(
180
+ f"SELECT assignment_id, is_interactive FROM assignments "
181
+ f"WHERE assignment_id IN ({placeholders})",
182
+ ids,
183
+ ).fetchall()
184
+ flags = {r["assignment_id"]: bool(r["is_interactive"]) for r in found}
185
+ except Exception: # noqa: BLE001 — best-effort enrichment only
186
+ return
187
+ for row in rows:
188
+ if row.assignment_id in flags:
189
+ row.is_interactive = flags[row.assignment_id]
190
+
191
+
192
+ def _select_winning_work_assignment(work_like: list["Assignment"]) -> "Assignment":
193
+ """The work-like row whose branch/verdicts the merge gate actually
194
+ tracks — the most-recently-dispatched one, ties won by the last in
195
+ iteration order. Mirrors
196
+ :func:`coord.merge_queue._select_winning_work_assignment`'s tie-break
197
+ without requiring every row to already be ``status == 'done'`` (unlike
198
+ that helper, this one is used for read-only diagnosis of a row that may
199
+ still be in flight)."""
200
+ winner = work_like[0]
201
+ for a in work_like[1:]:
202
+ if (a.dispatched_at or 0.0) >= (winner.dispatched_at or 0.0):
203
+ winner = a
204
+ return winner
205
+
206
+
207
+ def _find_verdict_unparseable_review(
208
+ entry: "mq.QueuedMerge", board: "Board",
209
+ ) -> "Assignment | None":
210
+ """Return a review row linked to *entry*'s work chain that finished
211
+ (``status == "done"``) with NO parseable ``review_verdict`` at all, or
212
+ ``None`` (#1956).
213
+
214
+ This is the exact defect #1956 reports: a review session completes —
215
+ sometimes with a full, well-reasoned body ending in ``END_REVIEW`` — but
216
+ the reviewer never emitted the machine-readable ``REVIEW_VERDICT:``
217
+ header, so ``review_verdict`` stays ``NULL`` forever. Before this,
218
+ ``has_approved_review`` reports exactly the same "not approved" for this
219
+ row as it would for a review that simply hasn't run yet, or one that
220
+ genuinely requested changes — three very different situations rendered
221
+ identically. Surfacing this distinctly here lets :func:`build_gate_report`
222
+ give the operator the actual recovery command instead of a generic
223
+ "review required but not approved" that suggests re-dispatching (which
224
+ #1956 explicitly calls out as "Not the fix" — it just re-derives a
225
+ conclusion already sitting in the log, and is a coin flip whether the
226
+ next attempt drops the header too).
227
+
228
+ Reuses :func:`coord.merge_queue._chain_work_ids` (the same branch/
229
+ ``review_of_assignment_id`` chain :func:`~coord.merge_queue.has_approved_review`
230
+ walks) so this never drifts from what that function actually considers
231
+ "linked to this entry". When more than one such row exists, the most
232
+ recently dispatched one is returned.
233
+ """
234
+ from coord import merge_queue as mq # noqa: PLC0415
235
+
236
+ pool = list(getattr(board, "completed", []) or []) + list(getattr(board, "active", []) or [])
237
+ branch_work_ids = mq._chain_work_ids(entry, pool) # noqa: SLF001
238
+ if not branch_work_ids:
239
+ return None
240
+ candidates = [
241
+ a for a in pool
242
+ if getattr(a, "type", None) == "review"
243
+ and getattr(a, "review_of_assignment_id", None) in branch_work_ids
244
+ and getattr(a, "status", None) == "done"
245
+ and getattr(a, "review_verdict", None) is None
246
+ ]
247
+ if not candidates:
248
+ return None
249
+ candidates.sort(key=lambda a: getattr(a, "dispatched_at", None) or 0.0)
250
+ return candidates[-1]
251
+
252
+
253
+ def build_gate_report(
254
+ board: "Board",
255
+ config: "Config",
256
+ repo_name: str,
257
+ issue_number: int,
258
+ gh_ops: "GhOps | None" = None,
259
+ ) -> GateReport:
260
+ """Read-only: board rows + the live review/test/merge gate decision for
261
+ one ``(repo_name, issue_number)``.
262
+
263
+ *gh_ops* (optional, duck-typed like ``coord.merge_queue.GhOps`` —
264
+ normally ``coord.github_ops`` itself) backs the #1479 freshness
265
+ comparison with LIVE branch/base SHAs and the branch's patch-id, mirroring
266
+ exactly what ``coord.merge_queue.process()`` populates on a
267
+ :class:`~coord.merge_queue.QueuedMerge` entry before evaluating its
268
+ gates (see that function's #821/#1475/#1479 comments). Passing ``None``
269
+ skips every live lookup — the decision section then reports only what
270
+ the recorded verdict's own stored anchors already imply (the same
271
+ fail-open convention #821/#1475 established), and the target branch
272
+ resolution falls back to ``repo.default_branch`` without a milestone
273
+ lookup.
274
+ """
275
+ from coord import merge_queue as mq # noqa: PLC0415
276
+
277
+ # #1730: match on the raw `issue_number` (the tracking issue keeps
278
+ # finding its own rows) OR the #1553 *effective* issue — a `for_issue_number`
279
+ # that resolves to *issue_number* means this row's work is FOR the issue
280
+ # being queried even though it's booked to a different (tracking) issue.
281
+ # #1553 taught the TUI this resolution (`Assignment::effective_issue_number`);
282
+ # this CLI had never been updated to match, so `coord gates <repo>
283
+ # <child>` reported "no assignments found" for oracle-loop slices whose
284
+ # only board row carried the tracking issue in `issue_number`.
285
+ matching = [
286
+ a
287
+ for a in (list(board.active) + list(board.completed))
288
+ if a.repo_name == repo_name
289
+ and (a.issue_number == issue_number or effective_issue_number(a) == issue_number)
290
+ ]
291
+ report = GateReport(repo_name=repo_name, issue_number=issue_number)
292
+ if not matching:
293
+ report.notes.append(
294
+ f"no assignments found on the board for {repo_name}#{issue_number}"
295
+ )
296
+ return report
297
+
298
+ matching.sort(key=lambda a: a.dispatched_at or 0.0)
299
+ report.rows = [_row_from_assignment(a) for a in matching]
300
+ _backfill_is_interactive(report.rows)
301
+
302
+ repo_cfg = config.repo(repo_name) if config is not None else None
303
+ if repo_cfg is None:
304
+ report.notes.append(
305
+ f"repo {repo_name!r} not in coordinator.yml — gate decision unavailable "
306
+ "(the raw columns above are still authoritative)"
307
+ )
308
+ return report
309
+
310
+ work_like = [a for a in matching if a.type in WORK_LIKE_TYPES]
311
+ if not work_like:
312
+ report.notes.append(
313
+ "no work-like assignment (work/test-author/mock-author) for this "
314
+ "issue — gate decision unavailable"
315
+ )
316
+ return report
317
+
318
+ winner = _select_winning_work_assignment(work_like)
319
+ report.branch = winner.branch
320
+ if not winner.branch:
321
+ report.notes.append(
322
+ f"winning work assignment {winner.assignment_id!r} has no branch — "
323
+ "gate decision unavailable"
324
+ )
325
+ return report
326
+
327
+ if gh_ops is not None:
328
+ from coord.branch_model import resolve_base_branch_for_issue_number # noqa: PLC0415
329
+
330
+ target_branch = resolve_base_branch_for_issue_number(
331
+ repo_cfg, repo_cfg.github, issue_number,
332
+ )
333
+ else:
334
+ target_branch = repo_cfg.default_branch
335
+ report.target_branch = target_branch
336
+
337
+ # A synthetic QueuedMerge — never persisted (this module never calls
338
+ # save_queue) — duck-typed identically to a real queue entry so it can be
339
+ # handed straight to merge_queue's own gate functions instead of a
340
+ # second, driftable reimplementation of the #1479 freshness math.
341
+ #
342
+ # #821/#1479: `mq.live_gate_entry` also populates the freshness anchors
343
+ # LIVE (when *gh_ops* is supplied) — mirrors exactly what
344
+ # merge_queue.process() does before evaluating the review/smoke gates.
345
+ # This matters because has_approved_review does NOT itself backfill
346
+ # branch_head_sha (only evaluate_smoke_verdict opportunistically
347
+ # backfills base/branch SHAs and patch-id on demand) — without doing it
348
+ # here, a row that never went through a live `coord merge`/auto-drain
349
+ # tick would show every staleness check as a silent no-op.
350
+ # #2085: this construction used to live inline here; it is now shared
351
+ # with every other caller that gate-checks a raw work Assignment
352
+ # (`merge_queue.enqueue_approved_work`, `coord.notify`'s stalled-dispatch
353
+ # recovery, `coord.diagnose`'s stage-work recovery, `coord.commands.
354
+ # merge`'s auto-enqueue scan) via `mq.live_gate_entry`, so this report
355
+ # can never drift from what those call sites decide (#2096: one function
356
+ # answering one question, not several that happen to agree today).
357
+ # When *gh_ops* is None the anchors stay unpopulated — and, since #2085,
358
+ # `has_approved_review` treats an unset branch head as UNCONFIRMED
359
+ # rather than "nothing to compare, trust the verdict": a review carrying
360
+ # a `review_head_sha` reports the review gate BLOCKED, not READY, when
361
+ # this diagnostic has no live GitHub access. That is intentional — a
362
+ # `coord gates` run with no `gh_ops` must not report "merge READY" for
363
+ # something a real `coord merge --dry-run` (which always has `gh_ops`)
364
+ # would refuse; see `tests/test_gates.py::
365
+ # test_gh_ops_none_skips_live_lookups_fail_open`.
366
+ entry = mq.live_gate_entry(winner, repo_cfg.github, target_branch, gh_ops)
367
+
368
+ review_required = mq.requires_review(entry, config)
369
+ review_ok = True
370
+ review_reason: str | None = None
371
+ review_verdict_unparseable = False
372
+ if review_required:
373
+ review_ok = mq.has_approved_review(entry, board, gh_ops)
374
+ if not review_ok:
375
+ # #1956: don't lump "verdict capture failed" in with the
376
+ # generic "not approved" — a review row that finished with no
377
+ # parseable verdict needs OPERATOR RECOVERY, not another
378
+ # dispatched review (see _find_verdict_unparseable_review's
379
+ # docstring). Checked only on the refusal path — a review that
380
+ # actually approved never reaches here.
381
+ unparseable = _find_verdict_unparseable_review(entry, board)
382
+ if unparseable is not None:
383
+ review_verdict_unparseable = True
384
+ review_reason = (
385
+ f"review {unparseable.assignment_id!r} finished with NO "
386
+ "parseable verdict (#1956) — needs operator recovery, "
387
+ "not a pending/failed review: `coord report-result "
388
+ f"--assignment {unparseable.assignment_id} --verdict "
389
+ "<approve|request-changes> --verdict-source recovered "
390
+ '--verdict-reason "<why>" --body-file '
391
+ "<extracted-review.md>`"
392
+ )
393
+ else:
394
+ review_reason = "review required but not approved"
395
+ report.decisions.append(
396
+ GateDecision(
397
+ gate="review", required=review_required, ok=review_ok, reason=review_reason,
398
+ verdict_unparseable=review_verdict_unparseable,
399
+ )
400
+ )
401
+
402
+ smoke_required = mq.requires_smoke(entry, config)
403
+ smoke_status = mq.evaluate_smoke_verdict(entry, board, gh_ops) if smoke_required else None
404
+ test_ok = (not smoke_required) or bool(smoke_status and smoke_status.ok)
405
+ test_decision = GateDecision(gate="test", required=smoke_required, ok=test_ok)
406
+ if smoke_status is not None:
407
+ # #2024: recorded on BOTH paths, not just the refusal — "which row is
408
+ # this verdict actually about" is the question a green summary hides.
409
+ test_decision.assignment_id = smoke_status.assignment_id
410
+ if smoke_status is not None and not smoke_status.ok:
411
+ test_decision.reason = smoke_status.message
412
+ test_decision.anchor = smoke_status.anchor
413
+ test_decision.recorded_sha = smoke_status.recorded_sha
414
+ test_decision.current_sha = smoke_status.current_sha
415
+ report.decisions.append(test_decision)
416
+
417
+ # #2024: the two readings, reconciled by SAYING WHICH ROW EACH IS ABOUT.
418
+ # The merge gate is branch-scoped by design (#1819: a Test run measures the
419
+ # (branch, base) pair), so on a `--fix-of` chain — where every round is a
420
+ # new work row on the SAME branch — it is routinely satisfied by the
421
+ # PARENT's verdict. The per-iteration readers (`coord drive`'s
422
+ # `work_test_state`, `dispatch_pending_reviews`, `auto_loop`'s #1612 gate)
423
+ # key on the CURRENT row's own verdict and correctly hold when it is
424
+ # empty. Both are right; only the summary was silent about the difference,
425
+ # which is what turned a blocked pipeline into an invisible one (25 min,
426
+ # then 160 min, on vimcode#635).
427
+ if (
428
+ test_ok
429
+ and smoke_required
430
+ and test_decision.assignment_id
431
+ and winner.assignment_id
432
+ and test_decision.assignment_id != winner.assignment_id
433
+ and winner.test_state not in ("passed", "skipped")
434
+ ):
435
+ report.notes.append(
436
+ f"test PASSED is the branch-scoped merge reading: the verdict was "
437
+ f"recorded on {test_decision.assignment_id}, not on this branch's "
438
+ f"current work row {winner.assignment_id} "
439
+ f"(review_iteration={winner.review_iteration or 0}, "
440
+ f"test_state={winner.test_state or 'none'}). Per-iteration readers "
441
+ "— `coord drive`'s Test stage and review auto-dispatch "
442
+ "(pipeline.test_precedes_review) — gate on the CURRENT row's own "
443
+ "verdict, so review dispatch can still be held while this line "
444
+ "reads passed. Record one with `coord test "
445
+ f"{winner.assignment_id} --passed` (or --skipped) once the fix "
446
+ "round has actually been tested (#2024)."
447
+ )
448
+
449
+ merge_blocked_gate: str | None = None
450
+ if review_required and not review_ok:
451
+ merge_blocked_gate = REVIEW_REQUIRED
452
+ elif smoke_required and not test_ok:
453
+ merge_blocked_gate = SMOKE_REQUIRED
454
+ merge_decision = GateDecision(
455
+ gate="merge", required=True, ok=merge_blocked_gate is None, reason=merge_blocked_gate,
456
+ )
457
+ report.decisions.append(merge_decision)
458
+ if merge_decision.ok:
459
+ report.notes.append(
460
+ "merge READY reflects the review/test gates only — CI checks and the "
461
+ "#1318 epic-closing-keyword guard are evaluated live by `coord merge`/"
462
+ "`coord merge --plan`, not by `coord gates`."
463
+ )
464
+
465
+ return report
466
+
467
+
468
+ def _short_sha(sha: str | None) -> str:
469
+ return sha[:7] if sha else "unknown"
470
+
471
+
472
+ def format_gate_report(report: GateReport) -> str:
473
+ """Human-readable rendering of *report* for the CLI's default (non-JSON) output."""
474
+ lines: list[str] = [f"gates {report.repo_name}#{report.issue_number}"]
475
+
476
+ for row in report.rows:
477
+ lines.append(
478
+ f" [{row.type}] {row.assignment_id or '?'} status={row.status} "
479
+ f"branch={row.branch or '-'} machine={row.machine_name or '-'}"
480
+ + (f" provider={row.provider_name}" if row.provider_name else "")
481
+ + (f" interactive={row.is_interactive}" if row.is_interactive is not None else "")
482
+ # #1730: legible two-number reality — only printed when the row's
483
+ # attribution differs from the issue it's booked to, so an
484
+ # ordinary row (no `for_issue_number`, or one equal to
485
+ # `issue_number`) renders exactly as it did before this field
486
+ # existed.
487
+ + (
488
+ f" booked_to=#{row.issue_number} for=#{row.for_issue_number}"
489
+ if row.for_issue_number is not None and row.for_issue_number != row.issue_number
490
+ else ""
491
+ )
492
+ )
493
+ lines.append(
494
+ f" test_state={row.test_state} smoke_test={row.smoke_test} "
495
+ f"test_reason={row.test_reason!r}"
496
+ )
497
+ lines.append(
498
+ f" test_toolchain={row.test_toolchain or 'unknown'}"
499
+ )
500
+ lines.append(
501
+ f" review_state={row.review_state} review_verdict={row.review_verdict} "
502
+ f"review_of_assignment_id={row.review_of_assignment_id or '-'}"
503
+ )
504
+ # #1956: only printed when this row actually carries a verdict AND a
505
+ # provenance value that is NOT the default "agent" — a plain
506
+ # "verdict_source=agent" on every ordinary row (the overwhelming
507
+ # majority, including every row `coord report-result --verdict`
508
+ # persists going forward — see `issue_store._persist_verdict_source`,
509
+ # which always stamps a source, never leaves the column NULL) would
510
+ # be exactly the noise this line was written to avoid. `None` and
511
+ # the literal `"agent"` both mean "an agent produced this verdict"
512
+ # (see coord.models.Assignment.verdict_source) — only surface the
513
+ # cases that need a human's attention: recovered/overridden.
514
+ if row.review_verdict is not None and row.verdict_source not in (None, "agent"):
515
+ lines.append(
516
+ f" verdict_source={row.verdict_source}"
517
+ + (
518
+ f" reason={row.verdict_source_reason!r}"
519
+ if row.verdict_source_reason
520
+ else ""
521
+ )
522
+ )
523
+
524
+ if report.decisions:
525
+ by_gate = {d.gate: d for d in report.decisions}
526
+ lines.append("")
527
+ lines.append(
528
+ f"Gate decision (branch {report.branch or '?'} -> {report.target_branch or '?'}):"
529
+ )
530
+ review = by_gate.get("review")
531
+ if review is not None:
532
+ if not review.required:
533
+ lines.append(" review : not required")
534
+ elif review.ok:
535
+ lines.append(" review : approve")
536
+ elif review.verdict_unparseable:
537
+ # #1956: deliberately NOT "BLOCKED" — that word reads
538
+ # identically to "not yet reviewed" / "requested changes",
539
+ # exactly the ambiguity #1956 reports. This needs operator
540
+ # recovery, not a wait.
541
+ lines.append(f" review : ERROR — {review.reason}")
542
+ else:
543
+ lines.append(f" review : BLOCKED — {review.reason}")
544
+ test = by_gate.get("test")
545
+ if test is not None:
546
+ if not test.required:
547
+ lines.append(" test : not required")
548
+ elif test.ok:
549
+ # #2024: name the row the verdict sits on. `coord gates` and
550
+ # `coord drive` are allowed to answer differently (branch gate
551
+ # vs per-iteration gate) — they are not allowed to do it
552
+ # silently.
553
+ lines.append(
554
+ " test : passed"
555
+ + (f" (recorded on {test.assignment_id})" if test.assignment_id else "")
556
+ )
557
+ elif test.anchor:
558
+ noun = "base" if test.anchor == "base" else "branch"
559
+ lines.append(
560
+ f" test : STALE — recorded against {noun} "
561
+ f"{_short_sha(test.recorded_sha)}, {noun} now "
562
+ f"{_short_sha(test.current_sha)} (#1479)"
563
+ )
564
+ lines.append(f" {test.reason}")
565
+ else:
566
+ lines.append(f" test : BLOCKED — {test.reason}")
567
+ merge = by_gate.get("merge")
568
+ if merge is not None:
569
+ lines.append(
570
+ " merge : READY" if merge.ok else f" merge : BLOCKED — {merge.reason}"
571
+ )
572
+
573
+ for note in report.notes:
574
+ lines.append(f" note: {note}")
575
+
576
+ return "\n".join(lines)
577
+
578
+
579
+ def report_to_dict(report: GateReport) -> dict:
580
+ """JSON-safe ``dict`` for the CLI's ``--json`` flag / the ``/gates``
581
+ daemon response — plain nested dicts/lists, no dataclass instances."""
582
+ return asdict(report)