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,650 @@
1
+ """Server-side per-issue stage/gate projection (#550, generalizes #776/#778).
2
+
3
+ ``coord-tui``'s ``tui/src/app/pipeline.rs`` independently re-derives every
4
+ stage/gate computation from the raw ``/board`` rows — ``stage_status_for``,
5
+ ``merge_stage_status_for``, ``test_stage_status_for``, and
6
+ ``issue_has_any_approved_review`` (which duplicates
7
+ ``coord.merge_queue.has_approved_review``'s intent, but keyed by issue
8
+ number rather than branch). This module computes the same *DB-derivable*
9
+ subset of that logic once, in Python, so it can be injected into ``/board``
10
+ (``coord/serve_app.py``) and consumed by the TUI instead of re-implemented.
11
+
12
+ Deliberately excluded — genuinely TUI-session-local state with no server
13
+ equivalent, so it stays a client-side overlay on top of this projection:
14
+
15
+ * the optimistic "merge just dispatched" flag (``pipeline_inflight_merges``)
16
+ set the instant the Go button is pressed, before the DB round-trip lands;
17
+ * a locally-spawned Phase-1 build subprocess (``test_build_in_flight``);
18
+ * the CI-check cache the TUI itself polls via the GitHub API
19
+ (``pipeline_ci_checks``) — this module uses the server's own ``CiStore``
20
+ instead, which is a *different* (also valid) CI signal source already
21
+ wired into ``coord.merge_queue``'s gate evaluation.
22
+
23
+ Because local-SQLite-mode coord-tui (no ``coord serve`` daemon configured)
24
+ has no server to ask, the Rust functions this mirrors are NOT deleted —
25
+ they remain the local-mode fallback. The daemon path prefers this
26
+ projection when present. See #550 for the full rationale.
27
+
28
+ Pure computation: every function here takes already-loaded data and returns
29
+ plain values — no I/O, no side effects.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import Any, Protocol, runtime_checkable
35
+
36
+ from coord.models import CLOSES_ISSUE_TYPES
37
+
38
+ # ── Stage-status vocabulary — mirrors tui/src/app/pipeline.rs::StageStatus ──
39
+ PENDING = "pending"
40
+ ACTIVE = "active"
41
+ DONE = "done"
42
+ FAILED = "failed"
43
+ STALE = "stale"
44
+ SKIPPED = "skipped"
45
+
46
+ _MERGED_STATES = frozenset({"merged"})
47
+ _ACTIVE_MERGE_STATES = frozenset({"open", "queued"})
48
+ _FAILED_MERGE_STATES = frozenset({"failed", "human_required"})
49
+
50
+
51
+ @runtime_checkable
52
+ class _AssignmentLike(Protocol):
53
+ assignment_id: str | None
54
+ type: str
55
+ status: str
56
+ dispatched_at: float | None
57
+ review_verdict: str | None
58
+ review_of_assignment_id: str | None
59
+ test_state: str | None
60
+ repo_name: str
61
+ issue_number: int
62
+ acceptance_state: str | None
63
+ acceptance_total: int | None
64
+ acceptance_passed: int | None
65
+
66
+
67
+ # ── Helpers ──────────────────────────────────────────────────────────────
68
+
69
+
70
+ def _latest_by_dispatch(assignments: list) -> Any | None:
71
+ """Return the assignment with the max ``dispatched_at`` (None sorts last,
72
+ matching Rust's ``partial_cmp(...).unwrap_or(Equal)`` on an Option)."""
73
+ if not assignments:
74
+ return None
75
+ return max(
76
+ assignments,
77
+ key=lambda a: (a.dispatched_at is not None, a.dispatched_at or 0.0),
78
+ )
79
+
80
+
81
+ def _issue_has_plan_assignment(assignments_for_issue: list) -> bool:
82
+ return any(a.type == "plan" for a in assignments_for_issue)
83
+
84
+
85
+ def _work_ids_for_issue(assignments_for_issue: list, seed_work_id: str | None = None) -> set[str]:
86
+ """Every ``CLOSES_ISSUE_TYPES`` assignment id for this issue, plus
87
+ *seed_work_id* when given (#292: a queue entry keyed to a work id whose
88
+ row has since been pruned from the board)."""
89
+ work_ids = {
90
+ a.assignment_id
91
+ for a in assignments_for_issue
92
+ if a.type in CLOSES_ISSUE_TYPES and a.assignment_id
93
+ }
94
+ if seed_work_id:
95
+ work_ids.add(seed_work_id)
96
+ return work_ids
97
+
98
+
99
+ def _review_verdict_events(assignments_for_issue: list, work_ids: set[str]) -> list:
100
+ """Every verdict-bearing event connected to *work_ids*: dedicated
101
+ ``type="review"`` rows scoped by ``review_of_assignment_id``, AND (#331)
102
+ a verdict stamped directly on a work row itself (self-approval /
103
+ PR-comment-fallback, no separate reviewer dispatched).
104
+
105
+ #2085 (non-blocking finding): shared by :func:`issue_has_any_approved_review`
106
+ and the "review" stage branch of :func:`stage_status_for` so the two can
107
+ never disagree about which events count. Before this was factored out,
108
+ ``stage_status_for``'s ``assignments_for_stage(..., "review", ...)`` only
109
+ ever matched ``type == "review"`` rows — a self-approval stamped directly
110
+ on a work row (no dedicated review assignment at all) was invisible to
111
+ it, so ``has_approved_review: True`` could sit next to
112
+ ``stages["review"]`` reading ``PENDING``/``SKIPPED`` in the same
113
+ projection — the same READY-vs-refused disagreement #2085 is about, just
114
+ via a different path.
115
+ """
116
+ return [
117
+ a
118
+ for a in assignments_for_issue
119
+ if a.review_verdict
120
+ and (
121
+ (a.type == "review" and a.review_of_assignment_id in work_ids)
122
+ or a.assignment_id in work_ids
123
+ )
124
+ ]
125
+
126
+
127
+ def assignments_for_stage(
128
+ assignments_for_issue: list,
129
+ stage: str,
130
+ *,
131
+ require_plan: bool,
132
+ ) -> list:
133
+ """Mirrors ``pipeline.rs::assignments_for_stage``.
134
+
135
+ When the pipeline has no Plan stage in this issue's strip (no global
136
+ ``require_plan`` and no ``type="plan"`` assignment for this issue),
137
+ plan-typed assignments fold into "work" so a ``--plan-only`` dispatch
138
+ without ``require_plan`` doesn't disappear from the Work stage.
139
+ """
140
+ fold_plan_into_work = (
141
+ stage == "work"
142
+ and not require_plan
143
+ and not _issue_has_plan_assignment(assignments_for_issue)
144
+ )
145
+ out = []
146
+ for a in assignments_for_issue:
147
+ t = a.type or "work"
148
+ if fold_plan_into_work:
149
+ if t in ("work", "plan"):
150
+ out.append(a)
151
+ elif t == stage:
152
+ out.append(a)
153
+ return out
154
+
155
+
156
+ def upstream_max_dispatched_at(
157
+ assignments_for_issue: list,
158
+ stage: str,
159
+ stage_names: list[str],
160
+ *,
161
+ require_plan: bool,
162
+ ) -> float | None:
163
+ """Mirrors ``pipeline.rs::upstream_max_dispatched_at``."""
164
+ if stage not in stage_names:
165
+ return None
166
+ idx = stage_names.index(stage)
167
+ if idx == 0:
168
+ return None
169
+ best: float | None = None
170
+ for s in stage_names[:idx]:
171
+ for a in assignments_for_stage(assignments_for_issue, s, require_plan=require_plan):
172
+ if a.dispatched_at is not None:
173
+ best = a.dispatched_at if best is None else max(best, a.dispatched_at)
174
+ return best
175
+
176
+
177
+ def _has_active_conflict_fix(assignments_for_issue: list) -> bool:
178
+ """Mirrors ``pipeline.rs::has_active_conflict_fix`` (#241)."""
179
+ return any(
180
+ a.type == "conflict-fix" and a.status in ("running", "pending")
181
+ for a in assignments_for_issue
182
+ )
183
+
184
+
185
+ def _has_active_smoke_session(assignments_for_issue: list) -> bool:
186
+ """Mirrors ``pipeline.rs::has_active_smoke_session`` (#585)."""
187
+ return any(
188
+ a.type in ("smoke", "test-chat") and a.status in ("running", "pending")
189
+ for a in assignments_for_issue
190
+ )
191
+
192
+
193
+ def _ci_failed_for_entry(merge_entry: Any | None, ci_store: Any | None) -> bool:
194
+ """Mirrors ``pipeline.rs::ci_failed_for_entry``, sourced from the
195
+ server's ``CiStore`` rather than the TUI's own poll cache."""
196
+ if ci_store is None or not getattr(ci_store, "is_available", False):
197
+ return False
198
+ if merge_entry is None or getattr(merge_entry, "pr_number", None) is None:
199
+ return False
200
+ from coord.ci_store import failed_checks # noqa: PLC0415
201
+
202
+ checks = ci_store.list_checks_for_pr(merge_entry.repo_github, merge_entry.pr_number)
203
+ return bool(failed_checks(checks))
204
+
205
+
206
+ # ── Per-stage status functions ──────────────────────────────────────────
207
+
208
+
209
+ def stage_status_for_internal_work(
210
+ assignments_for_issue: list,
211
+ *,
212
+ is_closed: bool,
213
+ require_plan: bool,
214
+ ) -> str:
215
+ """Mirrors ``pipeline.rs::stage_status_for_internal_work``."""
216
+ matching = assignments_for_stage(assignments_for_issue, "work", require_plan=require_plan)
217
+ if any(a.status == "running" for a in matching):
218
+ return ACTIVE
219
+ latest = _latest_by_dispatch(matching)
220
+ if latest is not None:
221
+ if latest.status == "done":
222
+ return DONE
223
+ if latest.status == "failed":
224
+ return FAILED
225
+ return SKIPPED if is_closed else PENDING
226
+
227
+
228
+ def test_stage_status_for(
229
+ assignments_for_issue: list,
230
+ *,
231
+ is_closed: bool,
232
+ require_plan: bool,
233
+ ) -> str:
234
+ """Mirrors ``pipeline.rs::test_stage_status_for`` (#200/#235/#310/#585/#1395).
235
+
236
+ Excludes the #235 "Phase 1 build in flight" override — that's a locally
237
+ spawned TUI subprocess with no server-side equivalent; the TUI overlays
238
+ it on top of this value.
239
+ """
240
+ work_status = stage_status_for_internal_work(
241
+ assignments_for_issue, is_closed=is_closed, require_plan=require_plan
242
+ )
243
+ if work_status != DONE:
244
+ return SKIPPED if is_closed else PENDING
245
+
246
+ if _has_active_smoke_session(assignments_for_issue):
247
+ return ACTIVE
248
+
249
+ work = assignments_for_stage(assignments_for_issue, "work", require_plan=require_plan)
250
+ with_verdict = [a for a in work if (a.test_state or "") != ""]
251
+ verdict_assignment = _latest_by_dispatch(with_verdict)
252
+ verdict = verdict_assignment.test_state if verdict_assignment else None
253
+ # #1395: "running" is a transient, non-verdict marker — an unattended
254
+ # driver (scripts/drive-issue.sh) that bypasses dispatch_smoke and runs
255
+ # the suite locally sets it right before the run and overwrites it with a
256
+ # terminal passed/failed/skipped verdict when the run concludes. Without
257
+ # this, the Test box reads Pending (indistinguishable from "not started")
258
+ # for however long the local suite takes — the #1395 invisible-test-stage
259
+ # bug. It must never be treated as a verdict by any gate; the merge gate
260
+ # (`has_smoke_verdict`) and the review gate (`dispatch_pending_reviews`)
261
+ # both key off `test_state in ("passed", "skipped")`, so "running" already
262
+ # fails closed there without any change.
263
+ if verdict == "running":
264
+ return ACTIVE
265
+ if verdict in ("passed", "skipped"):
266
+ return DONE
267
+ if verdict == "failed":
268
+ return FAILED
269
+ # #1672: ``"blocked"`` (``coord.smoke.TEST_STATE_BLOCKED`` — no
270
+ # capability-matched machine could run the Test stage) deliberately falls
271
+ # through to PENDING here rather than mapping to FAILED. FAILED is a
272
+ # statement about the BRANCH, and the TUI acts on it: it would flip
273
+ # `can_redispatch_work_after_test_failure` on (offering to re-dispatch
274
+ # Work that is perfectly fine) and `test_gate_actionable` off (taking away
275
+ # the operator's `B`/verdict keys on the one row that needs them). The
276
+ # blocked reason reaches the operator through `test_reason` instead —
277
+ # rendered by `coord gates` and, since no smoke row exists in this case,
278
+ # by the TUI Summary tab's `board_assignment_reason` on the work row.
279
+ return PENDING
280
+
281
+
282
+ def acceptance_stage_status_for(assignments_for_issue: list) -> str:
283
+ """The Acceptance box's status (#932/#944) — reported and gated
284
+ *separately* from the Test box (docs/ORACLE_LOOP.md), so this mirrors
285
+ ``test_stage_status_for``'s shape but keys off
286
+ ``Assignment.acceptance_state`` rather than a dedicated assignment
287
+ type: ``coord acceptance record`` stamps the verdict directly onto the
288
+ work assignment row (see ``coord/commands/acceptance.py``), it never
289
+ spawns a separate ``type="acceptance"`` assignment.
290
+
291
+ Distinct from Test in one more way: an issue with no acceptance suite
292
+ authored yet (no manifest slice, so ``acceptance record`` was never run
293
+ against it) has no signal at all — SKIPPED rather than PENDING, since
294
+ the Acceptance box only applies to oracle-loop milestones, not every
295
+ issue on the board.
296
+ """
297
+ work = [a for a in assignments_for_issue if (a.type or "work") == "work"]
298
+ with_state = [a for a in work if (a.acceptance_state or "") != ""]
299
+ if not with_state:
300
+ return SKIPPED
301
+ latest = _latest_by_dispatch(with_state)
302
+ state = latest.acceptance_state
303
+ if state == "passed":
304
+ return DONE
305
+ if state == "failed":
306
+ return FAILED
307
+ return PENDING
308
+
309
+
310
+ def acceptance_progress_for(assignments_for_issue: list) -> dict[str, int] | None:
311
+ """``{"passed": p, "total": t}`` from the latest recorded acceptance
312
+ verdict for this issue, or ``None`` when no verdict exists yet or it
313
+ predates #932's per-test counts. Backs the Acceptance box's
314
+ partial-green display (e.g. "3/7 acceptance green") — a growing suite
315
+ is *expected* to read sub-100% until the feature completes
316
+ (docs/ORACLE_LOOP.md), so this is reporting, not a pass/fail gate.
317
+ """
318
+ work = [a for a in assignments_for_issue if (a.type or "work") == "work"]
319
+ with_state = [a for a in work if (a.acceptance_state or "") != ""]
320
+ if not with_state:
321
+ return None
322
+ latest = _latest_by_dispatch(with_state)
323
+ if latest.acceptance_total is None or latest.acceptance_passed is None:
324
+ return None
325
+ return {"passed": latest.acceptance_passed, "total": latest.acceptance_total}
326
+
327
+
328
+ def merge_stage_status_for(
329
+ assignments_for_issue: list,
330
+ merge_entry: Any | None,
331
+ *,
332
+ is_closed: bool,
333
+ ci_store: Any | None = None,
334
+ ) -> str:
335
+ """Mirrors ``pipeline.rs::merge_stage_status_for`` (#241/#290/#775).
336
+
337
+ Excludes the #290 "just dispatched, DB not yet caught up" optimistic
338
+ flag — that's TUI-session-local; the TUI overlays it on top.
339
+ """
340
+ if _has_active_conflict_fix(assignments_for_issue):
341
+ return ACTIVE
342
+
343
+ if merge_entry is not None:
344
+ state = merge_entry.state
345
+ if state in _MERGED_STATES:
346
+ return DONE
347
+ if state in _ACTIVE_MERGE_STATES:
348
+ return ACTIVE
349
+ if state in _FAILED_MERGE_STATES:
350
+ return FAILED
351
+
352
+ if _ci_failed_for_entry(merge_entry, ci_store):
353
+ return FAILED
354
+
355
+ # #775: the daemon's merge-reconcile tick prunes the queue row after
356
+ # flipping the work assignment to status="merged" — fall back to the
357
+ # assignment itself as evidence the Merge stage is Done.
358
+ # #1142: gate on CLOSES_ISSUE_TYPES, not a bare `type == "work"` — a
359
+ # merged assignment whose type doesn't actually resolve this issue (e.g.
360
+ # a `coord pr` PR-opening helper for a test-author/mock-author original,
361
+ # whose issue_number is a milestone tracking issue) must not be read as
362
+ # this issue's own work merging. See `coord.models.PR_HELPER_TYPE`.
363
+ if any(a.type in CLOSES_ISSUE_TYPES and a.status == "merged" for a in assignments_for_issue):
364
+ return DONE
365
+
366
+ return SKIPPED if is_closed else PENDING
367
+
368
+
369
+ def stage_status_for(
370
+ assignments_for_issue: list,
371
+ stage: str,
372
+ *,
373
+ stage_names: list[str],
374
+ is_closed: bool,
375
+ require_plan: bool,
376
+ merge_entry: Any | None = None,
377
+ ci_store: Any | None = None,
378
+ ) -> str:
379
+ """Mirrors ``pipeline.rs::stage_status_for`` — the generic per-stage
380
+ dispatcher, including the #193 "stale downstream verdict" check.
381
+
382
+ #2085: the "review" stage branch deliberately DIVERGES from
383
+ ``pipeline.rs`` in one respect — it also folds in a #331 self-approval
384
+ verdict stamped directly on a work row (see the ``stage == "review"``
385
+ block below). That closes a same-projection disagreement this server
386
+ endpoint can produce (``has_approved_review: True`` next to
387
+ ``stages["review"]`` reading ``PENDING``); local-mode ``coord-tui``
388
+ (no server) still has the older, narrower behavior until the Rust side
389
+ picks up the equivalent fix.
390
+ """
391
+ if stage == "merge":
392
+ return merge_stage_status_for(
393
+ assignments_for_issue, merge_entry, is_closed=is_closed, ci_store=ci_store
394
+ )
395
+ if stage == "test":
396
+ return test_stage_status_for(
397
+ assignments_for_issue, is_closed=is_closed, require_plan=require_plan
398
+ )
399
+
400
+ matching = assignments_for_stage(assignments_for_issue, stage, require_plan=require_plan)
401
+ if stage == "review":
402
+ # #2085 (non-blocking finding): `assignments_for_stage`'s strict
403
+ # `type == "review"` filter misses a #331 self-approval verdict
404
+ # stamped directly on a work row — fold in the SAME event set
405
+ # `issue_has_any_approved_review` scans so the "review" stage badge
406
+ # and the projection's `has_approved_review` field can't disagree.
407
+ work_ids = _work_ids_for_issue(assignments_for_issue)
408
+ seen = {id(a) for a in matching}
409
+ matching = matching + [
410
+ a for a in _review_verdict_events(assignments_for_issue, work_ids)
411
+ if id(a) not in seen and a.type != "review"
412
+ ]
413
+ # #1566: "finalizing" is a review row whose agent finished but whose
414
+ # verdict hasn't been parsed + persisted yet (`coord notify`'s slower,
415
+ # separate step — see `coord.reconcile.reconcile_completed_assignments`).
416
+ # Treat it exactly like "running" here: without this, the #473/#812
417
+ # verdict-based mapping below would read a still-in-flight review as a
418
+ # terminal "done" with no verdict and paint it FAILED — the same
419
+ # dead-end misread #1566 was filed over, just in the board's colours
420
+ # instead of `coord drive`'s exit.
421
+ if any(a.status in ("running", "finalizing") for a in matching):
422
+ return ACTIVE
423
+
424
+ latest = _latest_by_dispatch(matching)
425
+ if latest is not None:
426
+ mapped: str | None = None
427
+ if latest.status == "done" and stage == "review":
428
+ # #473/#812: key off the verdict, not merely that review ran.
429
+ mapped = DONE if latest.review_verdict == "approve" else FAILED
430
+ elif latest.status == "done":
431
+ mapped = DONE
432
+ elif latest.status == "failed":
433
+ mapped = FAILED
434
+
435
+ if mapped is not None:
436
+ if latest.dispatched_at is not None:
437
+ upstream = upstream_max_dispatched_at(
438
+ assignments_for_issue, stage, stage_names, require_plan=require_plan
439
+ )
440
+ if upstream is not None and upstream > latest.dispatched_at:
441
+ return STALE
442
+ return mapped
443
+
444
+ return SKIPPED if is_closed else PENDING
445
+
446
+
447
+ def issue_has_any_approved_review(
448
+ assignments_for_issue: list,
449
+ seed_work_id: str | None = None,
450
+ ) -> bool:
451
+ """Mirrors ``pipeline.rs::issue_has_any_approved_review`` (#292/#331).
452
+
453
+ Also the issue-scoped equivalent of
454
+ ``coord.merge_queue.has_approved_review`` (which is branch-scoped, keyed
455
+ off a single ``QueuedMerge`` entry) — this collects every work
456
+ assignment for the *issue* so a bounce-created fix worker's approval is
457
+ found even when a merge-queue entry is still keyed to the original work.
458
+
459
+ #2085: before this fix, ANY historical ``approve`` anywhere in the
460
+ issue's work chain counted, forever — including one superseded by a
461
+ later ``request-changes`` on newer commits. That produced a board
462
+ record with ``stages["review"] == "failed"`` (the general
463
+ ``stage_status_for`` dispatcher already keys off the *latest* review by
464
+ dispatch order) sitting next to ``has_approved_review: True`` in the
465
+ same projection — self-contradictory on its face, and the exact
466
+ daemon-vs-gate disagreement #2085 traces back to `coord drive` looping
467
+ on a merge that could never land.
468
+ ``coord.merge_queue.has_approved_review`` closes the equivalent gap with
469
+ a live commit-SHA/patch-id bind, which this module deliberately has no
470
+ I/O to perform (see module docstring). Dispatch order over the issue's
471
+ own recorded verdict history is the best available DB-only proxy, and —
472
+ crucially — it is the SAME ordering rule ``stage_status_for`` already
473
+ applies to the "review" stage badge computed alongside this field in
474
+ :func:`compute_issue_projection` (both now share
475
+ :func:`_review_verdict_events`, #2085), so the two can no longer disagree
476
+ the way the #2085 board record did — including the #331 self-approval
477
+ case, where earlier the "review" stage badge alone missed the event this
478
+ function already counted.
479
+ """
480
+ # #1142: CLOSES_ISSUE_TYPES, not a bare `type == "work"` — see the same
481
+ # rationale in `merge_stage_status_for` above.
482
+ work_ids = _work_ids_for_issue(assignments_for_issue, seed_work_id)
483
+ if not work_ids:
484
+ return False
485
+
486
+ # The MOST RECENTLY DISPATCHED event wins — mirrors `stage_status_for`'s
487
+ # own "latest by dispatch" rule for the "review" stage, so an earlier
488
+ # `approve` superseded by a later `request-changes` (on newer commits)
489
+ # no longer counts, matching what the merge gate's commit-bound check
490
+ # would also refuse.
491
+ events = _review_verdict_events(assignments_for_issue, work_ids)
492
+ if not events:
493
+ return False
494
+
495
+ return _latest_by_dispatch(events).review_verdict == "approve"
496
+
497
+
498
+ # ── Board-level projection ──────────────────────────────────────────────
499
+
500
+
501
+ def pipeline_stage_names(default_gates: list[str]) -> list[str]:
502
+ """Mirrors ``pipeline.rs::pipeline_stage_names`` (module-default, no
503
+ per-issue plan-assignment prepend — see ``issue_stage_names``)."""
504
+ stages = ["work"]
505
+ for g in default_gates:
506
+ # #1429: "merge" is restored to the per-issue stage-name ordering as
507
+ # a read-only observation badge (#738 retired the per-issue *box*
508
+ # with its Go/dispatch affordance — that reasoning covered the
509
+ # affordance, not observation; merge is still initiated solely from
510
+ # the Merge Queue panel). "work"/"plan" stay excluded here since
511
+ # they're prepended explicitly above / by the caller.
512
+ if g not in ("work", "plan"):
513
+ stages.append(g)
514
+ return stages
515
+
516
+
517
+ def issue_stage_names(assignments_for_issue: list, default_gates: list[str]) -> list[str]:
518
+ """Mirrors ``pipeline.rs::pipeline_stage_names_for_issue``."""
519
+ stages = pipeline_stage_names(default_gates)
520
+ if stages[0] != "plan" and _issue_has_plan_assignment(assignments_for_issue):
521
+ stages = ["plan", *stages]
522
+ return stages
523
+
524
+
525
+ def compute_issue_projection(
526
+ assignments_for_issue: list,
527
+ merge_entry: Any | None,
528
+ *,
529
+ is_closed: bool,
530
+ require_plan: bool,
531
+ default_gates: list[str],
532
+ ci_store: Any | None = None,
533
+ ) -> dict[str, Any]:
534
+ """Compute the full per-issue stage badge dict.
535
+
536
+ ``stages`` covers every name in this issue's stage strip (``plan``?,
537
+ ``work``, then the configured gates minus ``work``/``plan``) — including
538
+ ``merge`` (#1429: restored as a read-only observation badge; ``stage_status_for``
539
+ special-cases ``"merge"`` to delegate to ``merge_stage_status_for``, so the
540
+ loop below already produces the correct value) — plus a redundant explicit
541
+ ``merge`` assignment as a defensive fallback in case ``merge`` is ever
542
+ absent from ``names`` again (e.g. a future ``default_gates`` without it).
543
+ """
544
+ names = issue_stage_names(assignments_for_issue, default_gates)
545
+ stages: dict[str, str] = {}
546
+ for name in names:
547
+ stages[name] = stage_status_for(
548
+ assignments_for_issue,
549
+ name,
550
+ stage_names=names,
551
+ is_closed=is_closed,
552
+ require_plan=require_plan,
553
+ merge_entry=merge_entry,
554
+ ci_store=ci_store,
555
+ )
556
+ stages.setdefault(
557
+ "merge",
558
+ merge_stage_status_for(
559
+ assignments_for_issue, merge_entry, is_closed=is_closed, ci_store=ci_store
560
+ ),
561
+ )
562
+ # #932: the Acceptance box, computed unconditionally like "merge" above
563
+ # (own box, own verdict — reported separately from the Test stage) and
564
+ # excluded from the per-issue stage-strip ordering that `default_gates`
565
+ # drives, since it only applies to oracle-loop milestones.
566
+ stages["acceptance"] = acceptance_stage_status_for(assignments_for_issue)
567
+ return {
568
+ "stages": stages,
569
+ "acceptance_progress": acceptance_progress_for(assignments_for_issue),
570
+ "has_approved_review": issue_has_any_approved_review(
571
+ assignments_for_issue,
572
+ seed_work_id=merge_entry.assignment_id if merge_entry is not None else None,
573
+ ),
574
+ }
575
+
576
+
577
+ def compute_board_stage_projection(
578
+ *,
579
+ issues: list[dict],
580
+ assignments: list,
581
+ merge_queue_items: list,
582
+ default_gates: list[str],
583
+ require_plan: bool = False,
584
+ ci_store: Any | None = None,
585
+ ) -> list[dict[str, Any]]:
586
+ """Compute the per-issue stage projection for every issue that appears
587
+ on the board — the payload injected into ``GET /board`` as
588
+ ``issue_stage_projection``.
589
+
590
+ Issue keys are ``(repo_name, issue_number)`` — the union of the
591
+ ``issues`` table (open + recently-synced) and every assignment's
592
+ ``(repo_name, issue_number)`` (so closed issues with assignment history
593
+ still get a projection, matching what the TUI's Pipeline tab shows).
594
+ """
595
+ is_closed_by_key: dict[tuple[str, int], bool] = {
596
+ (i["repo_name"], i["number"]): str(i.get("state", "")).lower() == "closed"
597
+ for i in issues
598
+ }
599
+ issue_title_by_key: dict[tuple[str, int], str] = {
600
+ (i["repo_name"], i["number"]): i.get("title", "") for i in issues
601
+ }
602
+
603
+ assignments_by_key: dict[tuple[str, int], list] = {}
604
+ for a in assignments:
605
+ if not a.repo_name or a.issue_number is None:
606
+ continue
607
+ assignments_by_key.setdefault((a.repo_name, a.issue_number), []).append(a)
608
+
609
+ merge_by_key: dict[tuple[str, int], Any] = {}
610
+ for m in merge_queue_items:
611
+ assignment_type = getattr(m, "assignment_type", "work") or "work"
612
+ if assignment_type not in CLOSES_ISSUE_TYPES:
613
+ # This row's `issue_number` is the milestone's tracking issue, not
614
+ # something this PR resolves (#1077/#1084) — it's the sealed
615
+ # acceptance suite being delivered (docs/ORACLE_LOOP.md), not a
616
+ # merge that closes anyone's work. #1203 tried re-attributing it
617
+ # to the child issue named by the originating assignment's
618
+ # `for_issue_number`, but that just moved the false green from
619
+ # the epic to the child (claude-coordinator#1652) — the child's
620
+ # own work still has to be tested, reviewed, and merged on its
621
+ # own branch. There is no issue whose Merge box this row
622
+ # legitimately describes, so skip it entirely rather than
623
+ # attribute it anywhere.
624
+ continue
625
+ key = (m.repo_name, m.issue_number)
626
+ # First-match-wins, mirroring `.find()` over the id-ordered list —
627
+ # `load_queue()`/`board_projection()` both order by `id` ascending.
628
+ merge_by_key.setdefault(key, m)
629
+
630
+ keys = set(is_closed_by_key) | set(assignments_by_key)
631
+
632
+ result: list[dict[str, Any]] = []
633
+ for repo_name, issue_number in keys:
634
+ key = (repo_name, issue_number)
635
+ issue_assignments = assignments_by_key.get(key, [])
636
+ entry = compute_issue_projection(
637
+ issue_assignments,
638
+ merge_by_key.get(key),
639
+ is_closed=is_closed_by_key.get(key, False),
640
+ require_plan=require_plan,
641
+ default_gates=default_gates,
642
+ ci_store=ci_store,
643
+ )
644
+ entry["repo_name"] = repo_name
645
+ entry["issue_number"] = issue_number
646
+ entry["issue_title"] = issue_title_by_key.get(key, "")
647
+ result.append(entry)
648
+
649
+ result.sort(key=lambda e: (e["repo_name"], e["issue_number"]))
650
+ return result