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,709 @@
1
+ """Milestone gate state machine — S-1 of epic #1440 (#1929).
2
+
3
+ The skeleton the rest of #1440 hangs off: a durable, board-backed **gate
4
+ record** per driven milestone plus the pure transition function that walks it
5
+
6
+ Gate A (contract) → work → Gate B (architecture) → Gate C (acceptance)
7
+ → Gate D (ship) → done
8
+
9
+ exactly once, resumably. Where ``drive-issue.sh`` gets its resumability from
10
+ re-reading the board on every loop, the daemon gets it from
11
+ :func:`coord.state.list_milestone_gates`: the record says which gate a
12
+ milestone is in, when it entered, and what it is waiting on, so a daemon that
13
+ restarts mid-milestone picks the walk back up without ever re-running a gate
14
+ it already cleared.
15
+
16
+ **Every edge here is a "hold and report".** That is deliberate and is the
17
+ whole point of shipping this first. Each of #1440's other children replaces
18
+ exactly one hold in :func:`evaluate_gate` with a real decision (Gate-A pause,
19
+ contract amendment, red acceptance, Gate-B request-changes, Gate-C red).
20
+ Until then a gate that cannot advance logs *why* and stays put. There is no
21
+ silent fall-through anywhere in this module — that is the failure mode that
22
+ produced the 240-minute advisory spin in the per-issue driver, and the one
23
+ thing this file exists to make impossible.
24
+
25
+ Layering
26
+ --------
27
+
28
+ - :func:`evaluate_gate` is **pure**: gate name + :class:`GateProbes` in, one
29
+ :class:`GateStep` out. No board, no GitHub, no clock. Every edge in the
30
+ machine is decided here and nowhere else.
31
+ - :func:`probe_milestone` is the I/O half — it turns a fetched
32
+ :class:`~coord.milestone_dispatch.MilestoneContext` plus a board snapshot
33
+ into a :class:`GateProbes`. Injectable probe callables keep tests off
34
+ ``gh``, mirroring ``coord.milestone_dispatch``'s ``GateAFileExists``.
35
+ - :func:`plan_sequence` walks the whole remaining sequence from a record's
36
+ current gate for ``--dry-run``, so the operator sees every gate and what
37
+ would happen at each — not just the next step.
38
+ - :func:`coord.serve_app._milestone_gate_tick` is the driver that persists
39
+ the result. ``work`` is the one gate with a side effect, and it delegates
40
+ to the existing drain (``coord.milestone_dispatch.plan_dispatch`` /
41
+ ``dispatch_entry``) rather than inventing a second dispatch path.
42
+
43
+ Relationship to ``milestone.auto_dispatch`` (#1929's explicit question)
44
+ ----------------------------------------------------------------------
45
+
46
+ They are **mutually exclusive per milestone, gate driving wins.**
47
+
48
+ ``milestone.auto_dispatch`` gates the *legacy standalone drain*
49
+ (``_milestone_drain_tick``): a milestone registered by ``coord milestone
50
+ dispatch`` whose frontier the daemon re-drains with no gate walk around it.
51
+ A milestone with a gate record is driven by ``_milestone_gate_tick`` instead,
52
+ which owns the drain as its ``work`` state — so ``_milestone_drain_tick``
53
+ **skips** any ``(repo_name, tracking_issue)`` that has a gate record. Without
54
+ that exclusion a milestone sitting at Gate A (contract missing, or a future
55
+ sibling's Gate-A pause) could still have its frontier dispatched by the
56
+ independently-gated drain path, which is precisely the "two gates disagreeing
57
+ about whether work may start" bug.
58
+
59
+ Gate driving is therefore its **own** opt-in, per milestone, via ``coord
60
+ milestone drive`` — not a global config flag. ``_milestone_gate_tick`` is
61
+ consequently *not* gated on ``milestone.auto_dispatch``: an operator who
62
+ explicitly asked for one milestone to be gate-driven has already given the
63
+ approval that flag exists to represent, and leaving the tick behind it would
64
+ mean a ``drive`` that silently does nothing.
65
+
66
+ Exactly one overseer (#1930, epic #1440 S-2)
67
+ ---------------------------------------------
68
+
69
+ #1440's acceptance says the "exactly one overseer" decision must be
70
+ structural, not documentary — #1870 is the counter-example this file exists
71
+ to not repeat: a second entry point observed stale state and raced the first,
72
+ producing a duplicate drive. Applied to a gate-controlled milestone, the
73
+ **daemon's gate tick is the sole owner of its ``work`` drain** — every other
74
+ entry point that could dispatch the same frontier must refuse or delegate,
75
+ never guess-and-race:
76
+
77
+ - ``coord milestone drive`` **delegates**. It writes a ``GateRecord`` (cold
78
+ start) or re-persists the existing one (resume) but never calls
79
+ :func:`apply_step` and never dispatches — only ``_milestone_gate_tick``
80
+ does either. Running it twice, from two operators or two machines, is
81
+ therefore inert past the first call: same key, same record, no side
82
+ effect. There is nothing here for a second caller to race.
83
+ - ``coord milestone dispatch`` (the manual, non-gate CLI) **refuses**. Before
84
+ #1930 it had no idea a gate record existed and would happily dispatch the
85
+ same ready frontier the gate tick's ``work`` state was about to (or had
86
+ just) dispatched — the exact "two gates disagreeing about whether work may
87
+ start" shape #1870 already taught this codebase to fear, just reached from
88
+ the operator's keyboard instead of a second host's timer. See
89
+ ``coord.commands.milestone.milestone_dispatch_cmd``'s gate-record check,
90
+ which runs before the GitHub fetch and before ``--dry-run``/``--next``, so
91
+ the refusal is unconditional and cheap.
92
+ - ``_milestone_drain_tick`` (the legacy ``milestone.auto_dispatch`` path)
93
+ **delegates** by skipping any milestone with a gate record — see above.
94
+ - A second daemon process ticking the same board is out of scope here: this
95
+ module has no leader-election of its own and assumes the fleet convention
96
+ documented in ``docs/DRIVE_QUEUE.md`` (one daemon owns tick duty) holds for
97
+ the milestone-gate tick too. ``_save_milestone_gate_local``'s whole-record
98
+ upsert is not a compare-and-swap, so two daemons ticking concurrently could
99
+ still interleave writes — that would need a real distributed lock, which is
100
+ a different, larger change than this issue's scope (client vs. daemon
101
+ entry points). Flagged here rather than left for the next reader to
102
+ discover the hard way.
103
+ """
104
+
105
+ from __future__ import annotations
106
+
107
+ import time
108
+ from dataclasses import dataclass, field, replace
109
+ from typing import TYPE_CHECKING, Callable
110
+
111
+ if TYPE_CHECKING:
112
+ from coord.config import Config
113
+ from coord.milestone_dispatch import MilestoneContext
114
+ from coord.models import Board, Repo
115
+
116
+ __all__ = [
117
+ "GATE_A",
118
+ "WORK",
119
+ "GATE_B",
120
+ "GATE_C",
121
+ "GATE_D",
122
+ "DONE",
123
+ "GATE_SEQUENCE",
124
+ "GATE_LABELS",
125
+ "TERMINAL_GATES",
126
+ "ADVANCE",
127
+ "HOLD",
128
+ "DISPATCH",
129
+ "TERMINAL",
130
+ "GateRecord",
131
+ "GateStep",
132
+ "GateProbes",
133
+ "evaluate_gate",
134
+ "probe_milestone",
135
+ "plan_sequence",
136
+ "apply_step",
137
+ "format_plan",
138
+ ]
139
+
140
+
141
+ # ── The gates ────────────────────────────────────────────────────────────────
142
+ #
143
+ # Names are the persisted wire values (they land in board_meta JSON), so
144
+ # renaming one is a migration, not a refactor. ``work`` is deliberately a
145
+ # peer *state* in this sequence and not a peer *machine*: the drain is the
146
+ # middle of this walk, entered only once Gate A has cleared and exited only
147
+ # once every work-order node is terminal.
148
+
149
+ GATE_A = "gate_a"
150
+ WORK = "work"
151
+ GATE_B = "gate_b"
152
+ GATE_C = "gate_c"
153
+ GATE_D = "gate_d"
154
+ DONE = "done"
155
+
156
+ GATE_SEQUENCE: tuple[str, ...] = (GATE_A, WORK, GATE_B, GATE_C, GATE_D, DONE)
157
+
158
+ GATE_LABELS: dict[str, str] = {
159
+ GATE_A: "Gate A — contract",
160
+ WORK: "work — drain the ready frontier",
161
+ GATE_B: "Gate B — architecture review",
162
+ GATE_C: "Gate C — full acceptance suite",
163
+ GATE_D: "Gate D — ship",
164
+ DONE: "done",
165
+ }
166
+
167
+ #: Gates from which the machine never leaves; reaching one deregisters the
168
+ #: milestone from gate driving (the gate-record analogue of
169
+ #: ``deregister_milestone_drain``).
170
+ TERMINAL_GATES: frozenset[str] = frozenset({DONE})
171
+
172
+ # Step actions.
173
+ ADVANCE = "advance"
174
+ HOLD = "hold"
175
+ DISPATCH = "dispatch"
176
+ TERMINAL = "terminal"
177
+
178
+
179
+ def next_gate(gate: str) -> str | None:
180
+ """The gate that follows *gate* in :data:`GATE_SEQUENCE`, or ``None``.
181
+
182
+ ``None`` both for a terminal gate and for an unknown one — callers treat
183
+ "nowhere to go" identically in either case.
184
+ """
185
+ try:
186
+ idx = GATE_SEQUENCE.index(gate)
187
+ except ValueError:
188
+ return None
189
+ if idx + 1 >= len(GATE_SEQUENCE):
190
+ return None
191
+ return GATE_SEQUENCE[idx + 1]
192
+
193
+
194
+ # ── The durable record ───────────────────────────────────────────────────────
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class GateRecord:
199
+ """One driven milestone's persisted position in the gate walk.
200
+
201
+ Stored as JSON under the ``milestone_gates`` ``board_meta`` key (see
202
+ :func:`coord.state.list_milestone_gates`), one entry per
203
+ ``(repo_name, tracking_issue)``. Everything the daemon needs to resume
204
+ without re-deriving history:
205
+
206
+ ``gate``
207
+ Which gate the milestone is in *right now*. A restart reads this and
208
+ resumes there — it never restarts the walk at :data:`GATE_A`, which
209
+ is what "never re-runs a completed gate" means concretely.
210
+ ``entered_at``
211
+ Wall-clock seconds when the milestone entered ``gate``. Stamped only
212
+ on an actual transition, so it survives arbitrarily many no-op ticks
213
+ and answers "how long has this been stuck".
214
+ ``waiting_on``
215
+ The current hold reason, verbatim from :func:`evaluate_gate`. Empty
216
+ while a gate is advancing. This is the field that makes a hold
217
+ *reported* rather than silent.
218
+ ``cleared``
219
+ Every gate this milestone has already left, in order. The audit
220
+ trail for "did we run Gate B twice"; also what a future sibling reads
221
+ to decide whether a bounce means re-entering a cleared gate.
222
+ """
223
+
224
+ repo_name: str
225
+ tracking_issue: int
226
+ gate: str = GATE_A
227
+ entered_at: float = 0.0
228
+ updated_at: float = 0.0
229
+ waiting_on: str = ""
230
+ milestone_number: int | None = None
231
+ cleared: tuple[str, ...] = field(default_factory=tuple)
232
+ #: Bumped when the persisted shape changes incompatibly. A record whose
233
+ #: schema this build doesn't understand is treated as absent (cold start)
234
+ #: rather than mis-parsed — see :meth:`from_dict`.
235
+ schema: int = 1
236
+
237
+ @property
238
+ def key(self) -> tuple[str, int]:
239
+ return (self.repo_name, self.tracking_issue)
240
+
241
+ @property
242
+ def label(self) -> str:
243
+ return f"{self.repo_name}#{self.tracking_issue}"
244
+
245
+ @property
246
+ def is_terminal(self) -> bool:
247
+ return self.gate in TERMINAL_GATES
248
+
249
+ def to_dict(self) -> dict:
250
+ return {
251
+ "schema": self.schema,
252
+ "repo_name": self.repo_name,
253
+ "tracking_issue": self.tracking_issue,
254
+ "gate": self.gate,
255
+ "entered_at": self.entered_at,
256
+ "updated_at": self.updated_at,
257
+ "waiting_on": self.waiting_on,
258
+ "milestone_number": self.milestone_number,
259
+ "cleared": list(self.cleared),
260
+ }
261
+
262
+ @classmethod
263
+ def from_dict(cls, raw: dict) -> "GateRecord | None":
264
+ """Rebuild a record from persisted JSON, or ``None`` if unusable.
265
+
266
+ Tolerant on purpose, in the same spirit as
267
+ ``coord.state._load_milestone_drains_raw``: one corrupt entry must
268
+ degrade to "this milestone isn't gate-driven" rather than take down
269
+ the whole tick for every other milestone. An unknown ``gate`` value
270
+ is rejected too — silently coercing it to Gate A would re-run
271
+ already-cleared gates, the exact thing this record exists to prevent.
272
+ """
273
+ if not isinstance(raw, dict):
274
+ return None
275
+ try:
276
+ if int(raw.get("schema", 1)) != 1:
277
+ return None
278
+ repo_name = raw["repo_name"]
279
+ tracking_issue = int(raw["tracking_issue"])
280
+ except (KeyError, TypeError, ValueError):
281
+ return None
282
+ if not isinstance(repo_name, str) or not repo_name:
283
+ return None
284
+
285
+ gate = raw.get("gate", GATE_A)
286
+ if gate not in GATE_SEQUENCE:
287
+ return None
288
+
289
+ milestone_number = raw.get("milestone_number")
290
+ if milestone_number is not None:
291
+ try:
292
+ milestone_number = int(milestone_number)
293
+ except (TypeError, ValueError):
294
+ milestone_number = None
295
+
296
+ cleared_raw = raw.get("cleared") or []
297
+ cleared = tuple(
298
+ g for g in cleared_raw if isinstance(g, str) and g in GATE_SEQUENCE
299
+ ) if isinstance(cleared_raw, list) else ()
300
+
301
+ def _f(key: str) -> float:
302
+ try:
303
+ return float(raw.get(key) or 0.0)
304
+ except (TypeError, ValueError):
305
+ return 0.0
306
+
307
+ waiting_on = raw.get("waiting_on") or ""
308
+ return cls(
309
+ repo_name=repo_name,
310
+ tracking_issue=tracking_issue,
311
+ gate=gate,
312
+ entered_at=_f("entered_at"),
313
+ updated_at=_f("updated_at"),
314
+ waiting_on=waiting_on if isinstance(waiting_on, str) else "",
315
+ milestone_number=milestone_number,
316
+ cleared=cleared,
317
+ )
318
+
319
+
320
+ # ── The transition function ──────────────────────────────────────────────────
321
+
322
+
323
+ @dataclass(frozen=True)
324
+ class GateStep:
325
+ """What the machine decided for one gate.
326
+
327
+ ``action`` is one of :data:`ADVANCE`, :data:`HOLD`, :data:`DISPATCH`
328
+ (``work`` only — drain the frontier and stay put) or :data:`TERMINAL`.
329
+ ``reason`` is always populated, including on an advance, so a log line or
330
+ a ``--dry-run`` row never has to say just "held" with no explanation.
331
+ """
332
+
333
+ gate: str
334
+ action: str
335
+ reason: str
336
+ to_gate: str | None = None
337
+ #: True when this step is a *projection* rather than a live decision —
338
+ #: set by :func:`plan_sequence` for gates downstream of the first hold,
339
+ #: whose real inputs cannot exist yet.
340
+ projected: bool = False
341
+
342
+ @property
343
+ def advances(self) -> bool:
344
+ return self.action == ADVANCE
345
+
346
+
347
+ @dataclass(frozen=True)
348
+ class GateProbes:
349
+ """The live inputs every gate edge is decided from.
350
+
351
+ Split out from :func:`evaluate_gate` so the machine itself is pure and a
352
+ test can drive any edge by constructing this directly — no GitHub, no
353
+ board, no ``gh``.
354
+ """
355
+
356
+ #: ``coord.milestone_dispatch.gate_a_status`` — a human-readable block
357
+ #: reason, or ``None`` when the contract exists (or the repo is outside
358
+ #: the oracle-loop model entirely and skips Gate A).
359
+ gate_a_blocked: str | None = None
360
+ #: ``coord.milestone_dispatch.is_milestone_complete`` — every work-order
361
+ #: node terminal.
362
+ work_complete: bool = False
363
+ #: How many work-order nodes are still not terminal (display only).
364
+ work_remaining: int = 0
365
+ #: ``coord.gate_b.latest_gate_b_verdict`` — ``"approve"``,
366
+ #: ``"request-changes"``, or ``None`` when no Gate B review has landed.
367
+ gate_b_verdict: str | None = None
368
+ #: Tri-state on purpose. ``None`` means *no durable Gate C result
369
+ #: exists* — which is today's reality: ``coord milestone gate-c`` is a
370
+ #: read-only operator run that records nothing on the board. Making it
371
+ #: durable is a #1440 sibling; until then Gate C always holds on ``None``
372
+ #: rather than guessing.
373
+ gate_c_green: bool | None = None
374
+ #: Whether the milestone has shipped. Gate D never ships anything from
375
+ #: here (``coord milestone ship`` is an explicit operator action); this
376
+ #: only *observes* that it happened so the walk can terminate. The
377
+ #: observable signal is the tracking issue's own GitHub state — ``ship``
378
+ #: closes it as the last step of a successful run, so this is true iff
379
+ #: that closure (by ``ship`` or otherwise) has landed. See
380
+ #: :func:`probe_milestone`.
381
+ shipped: bool = False
382
+ #: Whether the work order is empty — a tracking issue with no ``## Work
383
+ #: order`` block can't be driven, and saying so beats holding forever at
384
+ #: ``work`` with an empty frontier.
385
+ work_order_empty: bool = False
386
+
387
+
388
+ def evaluate_gate(gate: str, probes: GateProbes) -> GateStep:
389
+ """Decide what happens at *gate*, given *probes*. Pure.
390
+
391
+ The single place any gate edge is decided. Every branch below either
392
+ advances or produces an explicit, human-readable hold — there is no
393
+ ``else: pass`` and no path that returns "nothing to say". When a #1440
394
+ sibling lands a real decision for an edge, it replaces that edge's hold
395
+ here and the rest of the walk is unchanged.
396
+ """
397
+ if gate == GATE_A:
398
+ if probes.work_order_empty:
399
+ return GateStep(
400
+ gate, HOLD,
401
+ "tracking issue has no `## Work order` block — nothing to drive "
402
+ "(write one with `coord milestone write-order`)",
403
+ )
404
+ if probes.gate_a_blocked:
405
+ # SIBLING (#1440, Gate-A pause): today a missing contract is a
406
+ # plain wait-and-retry. The Gate-A child decides whether to
407
+ # pause the milestone, dispatch the mock author, or escalate.
408
+ return GateStep(gate, HOLD, probes.gate_a_blocked)
409
+ return GateStep(
410
+ gate, ADVANCE,
411
+ "contract present on the default branch — work may dispatch",
412
+ to_gate=WORK,
413
+ )
414
+
415
+ if gate == WORK:
416
+ if probes.work_complete:
417
+ return GateStep(
418
+ gate, ADVANCE,
419
+ "every work-order node is terminal", to_gate=GATE_B,
420
+ )
421
+ # The one gate with a side effect. Staying here IS the drain.
422
+ return GateStep(
423
+ gate, DISPATCH,
424
+ f"{probes.work_remaining} work-order node(s) not terminal — "
425
+ "draining the ready frontier",
426
+ )
427
+
428
+ if gate == GATE_B:
429
+ verdict = probes.gate_b_verdict
430
+ if verdict == "approve":
431
+ return GateStep(
432
+ gate, ADVANCE, "Gate B approved", to_gate=GATE_C,
433
+ )
434
+ if verdict == "request-changes":
435
+ # SIBLING (#1440, Gate-B request-changes): bouncing the milestone
436
+ # back to `work` with a rework cohort is that child's decision.
437
+ return GateStep(
438
+ gate, HOLD,
439
+ "Gate B returned request-changes — bounce handling is a "
440
+ "separate #1440 child; holding rather than shipping",
441
+ )
442
+ if verdict:
443
+ return GateStep(
444
+ gate, HOLD,
445
+ f"Gate B verdict {verdict!r} is not an approval — holding",
446
+ )
447
+ return GateStep(
448
+ gate, HOLD,
449
+ "no Gate B verdict yet — run `coord milestone gate-b <repo> "
450
+ "<tracking_issue>`",
451
+ )
452
+
453
+ if gate == GATE_C:
454
+ if probes.gate_c_green is True:
455
+ return GateStep(
456
+ gate, ADVANCE,
457
+ "full acceptance suite recorded green", to_gate=GATE_D,
458
+ )
459
+ if probes.gate_c_green is False:
460
+ # SIBLING (#1440, Gate-C red): deciding whether red means bounce,
461
+ # amend the contract, or file a follow-up is that child's job.
462
+ return GateStep(
463
+ gate, HOLD,
464
+ "full acceptance suite is red — red-acceptance handling is a "
465
+ "separate #1440 child; holding",
466
+ )
467
+ return GateStep(
468
+ gate, HOLD,
469
+ "no durable Gate C result exists — `coord milestone gate-c` is "
470
+ "read-only today and records nothing on the board; run it "
471
+ "manually (making it durable is a separate #1440 child)",
472
+ )
473
+
474
+ if gate == GATE_D:
475
+ if probes.shipped:
476
+ return GateStep(gate, ADVANCE, "milestone shipped", to_gate=DONE)
477
+ return GateStep(
478
+ gate, HOLD,
479
+ "ship is an explicit operator action — run `coord milestone ship "
480
+ "<repo> <tracking_issue>`, which merges feature/ms-NN into develop "
481
+ "*and then closes the tracking issue on success*; this gate "
482
+ "watches for that closure (not the ship command running) to "
483
+ "detect completion. If the tracking issue was already closed by "
484
+ "some other means, that satisfies this gate too — but a bare "
485
+ "`ship` run without a closed issue never will",
486
+ )
487
+
488
+ if gate == DONE:
489
+ return GateStep(
490
+ gate, TERMINAL, "milestone walk complete — deregistering",
491
+ )
492
+
493
+ # Unreachable via GateRecord.from_dict (which rejects unknown gates), but
494
+ # an explicit refusal beats falling off the end of the function.
495
+ return GateStep(gate, HOLD, f"unknown gate {gate!r} — holding")
496
+
497
+
498
+ def apply_step(record: GateRecord, step: GateStep, *, now: float | None = None) -> GateRecord:
499
+ """Fold *step* into *record*, returning the record to persist.
500
+
501
+ An advance stamps a fresh ``entered_at`` and appends the gate just left
502
+ to ``cleared`` — that append is what "never re-runs a completed gate"
503
+ is written down as. A hold only refreshes ``waiting_on``/``updated_at``,
504
+ so a milestone stuck for an hour still reports the timestamp it actually
505
+ entered the gate.
506
+ """
507
+ stamp = time.time() if now is None else now
508
+ if step.advances and step.to_gate:
509
+ cleared = record.cleared
510
+ if record.gate not in cleared:
511
+ cleared = (*cleared, record.gate)
512
+ return replace(
513
+ record,
514
+ gate=step.to_gate,
515
+ entered_at=stamp,
516
+ updated_at=stamp,
517
+ waiting_on="",
518
+ cleared=cleared,
519
+ )
520
+ return replace(
521
+ record,
522
+ updated_at=stamp,
523
+ waiting_on="" if step.action in (ADVANCE, DISPATCH) else step.reason,
524
+ entered_at=record.entered_at or stamp,
525
+ )
526
+
527
+
528
+ # ── The I/O half ─────────────────────────────────────────────────────────────
529
+
530
+ #: ``(repo_cfg, config, milestone_number) -> block reason | None``. Injected
531
+ #: so tests never touch ``gh`` — mirrors ``milestone_dispatch.GateAFileExists``.
532
+ GateAProbe = Callable[["Repo", "Config", int], "str | None"]
533
+ #: ``(board, repo_name, tracking_issue, milestone_number) -> verdict | None``.
534
+ GateBProbe = Callable[["Board", str, int, int], "str | None"]
535
+
536
+
537
+ def probe_milestone(
538
+ ctx: "MilestoneContext",
539
+ board: "Board",
540
+ config: "Config",
541
+ repo_cfg: "Repo",
542
+ *,
543
+ gate_a_probe: GateAProbe | None = None,
544
+ gate_b_probe: GateBProbe | None = None,
545
+ ) -> GateProbes:
546
+ """Gather every live input :func:`evaluate_gate` needs, in one place.
547
+
548
+ Reuses the *existing* gate readers verbatim
549
+ (:func:`coord.milestone_dispatch.gate_a_status`,
550
+ :func:`coord.gate_b.latest_gate_b_verdict`) rather than reimplementing
551
+ them, so the gate walk and the manual CLI commands can never disagree
552
+ about whether a gate is satisfied.
553
+
554
+ ``shipped`` is read off the tracking issue's own state: closing the epic
555
+ is the observable end of the walk, and it costs nothing extra — the
556
+ tracking issue is already fetched by
557
+ :func:`~coord.milestone_dispatch.fetch_milestone_context`. ``coord
558
+ milestone ship`` (``coord/commands/milestone.py``) closes the tracking
559
+ issue itself once the merge succeeds, specifically so this probe has
560
+ something to observe — Gate D does not hold forever waiting on a manual
561
+ close that nothing in the pipeline actually performs.
562
+
563
+ This always computes *every* probe, regardless of which gate the
564
+ milestone is currently sitting in — a milestone parked at Gate C still
565
+ pays for a Gate-A contract-file fetch every tick. That is deliberate,
566
+ not an oversight: :func:`plan_sequence` evaluates every gate from the
567
+ current one through :data:`DONE` against this *same* probe set to build
568
+ the ``--dry-run`` projection, so the downstream probes have to exist
569
+ even when the live gate doesn't need them yet. Making this lazy would
570
+ mean threading "which gates does the caller actually need" through two
571
+ layers to save one GitHub call per tick — a real but minor cost, not a
572
+ correctness issue.
573
+ """
574
+ from coord.gate_b import latest_gate_b_verdict # noqa: PLC0415
575
+ from coord.milestone_dispatch import gate_a_status, is_milestone_complete # noqa: PLC0415
576
+
577
+ a_probe = gate_a_probe or (
578
+ lambda r, c, m: gate_a_status(r, c, m)
579
+ )
580
+ b_probe = gate_b_probe or (
581
+ lambda b, rn, ti, mn: latest_gate_b_verdict(b, rn, ti, mn)
582
+ )
583
+
584
+ nodes = list(ctx.work_order.nodes)
585
+ remaining = [n for n in nodes if n.issue_number not in ctx.terminal_issues]
586
+
587
+ return GateProbes(
588
+ gate_a_blocked=a_probe(repo_cfg, config, ctx.milestone_number),
589
+ work_complete=bool(nodes) and is_milestone_complete(ctx),
590
+ work_remaining=len(remaining),
591
+ gate_b_verdict=b_probe(
592
+ board, repo_cfg.name, ctx.tracking_issue, ctx.milestone_number
593
+ ),
594
+ # Tri-state None — see GateProbes.gate_c_green. No durable Gate C
595
+ # record exists on the board today, so this is never True/False yet.
596
+ gate_c_green=None,
597
+ shipped=str(ctx.tracking_issue_state or "").upper() == "CLOSED",
598
+ work_order_empty=not nodes,
599
+ )
600
+
601
+
602
+ # ── Dry-run: the whole remaining sequence ────────────────────────────────────
603
+
604
+
605
+ def plan_sequence(record: GateRecord, probes: GateProbes) -> list[GateStep]:
606
+ """The full remaining walk from ``record.gate``, one step per gate.
607
+
608
+ Every gate from the current one through :data:`DONE` appears — not just
609
+ the next one — which is what makes ``--dry-run`` reviewable. Gates after
610
+ the first hold are still evaluated against the *current* probes and
611
+ flagged ``projected=True``: their real inputs cannot exist yet (there is
612
+ no Gate B verdict while work is still draining), so the row shows what
613
+ they would say today, honestly labelled, rather than being omitted.
614
+ """
615
+ try:
616
+ start = GATE_SEQUENCE.index(record.gate)
617
+ except ValueError:
618
+ return [evaluate_gate(record.gate, probes)]
619
+
620
+ steps: list[GateStep] = []
621
+ stalled = False
622
+ for gate in GATE_SEQUENCE[start:]:
623
+ step = evaluate_gate(gate, probes)
624
+ steps.append(replace(step, projected=stalled))
625
+ if not step.advances:
626
+ # The walk stops advancing here; everything past this point is a
627
+ # projection, but keep emitting it so the operator sees the shape
628
+ # of the whole milestone.
629
+ stalled = True
630
+ return steps
631
+
632
+
633
+ def format_plan(
634
+ record: GateRecord,
635
+ steps: list[GateStep],
636
+ *,
637
+ to_dispatch: list = (),
638
+ skipped: list = (),
639
+ waiting: list = (),
640
+ footer: bool = True,
641
+ ) -> list[str]:
642
+ """Render a ``--dry-run`` report: the gate walk plus the work frontier.
643
+
644
+ ``to_dispatch``/``skipped``/``waiting`` come straight off a
645
+ :class:`~coord.milestone_dispatch.MilestonePlan` (the same frontier
646
+ ``coord milestone dispatch --dry-run`` prints), so the operator sees both
647
+ halves of #1440's fourth acceptance bullet — every gate, *and* what would
648
+ dispatch — in one output. Returns lines rather than echoing so both the
649
+ CLI and a test can consume it.
650
+
651
+ ``footer`` controls whether the trailing blank line + "(dry run — ...)"
652
+ line is included. A non-dry-run confirmation (``coord milestone drive``
653
+ without ``--dry-run``) wants the same report *without* that footer, since
654
+ the record was in fact written — pass ``footer=False`` there rather than
655
+ slicing the returned list, which silently breaks if this function's own
656
+ footer shape ever changes.
657
+ """
658
+ lines: list[str] = [
659
+ f"Gate walk for {record.label}"
660
+ + (
661
+ f" (milestone #{record.milestone_number})"
662
+ if record.milestone_number is not None
663
+ else ""
664
+ ),
665
+ f" current gate: {GATE_LABELS.get(record.gate, record.gate)}",
666
+ ]
667
+ if record.cleared:
668
+ lines.append(
669
+ " already cleared: "
670
+ + ", ".join(GATE_LABELS.get(g, g) for g in record.cleared)
671
+ )
672
+ lines.append("")
673
+ lines.append("Planned sequence:")
674
+
675
+ marks = {ADVANCE: "->", HOLD: "||", DISPATCH: "**", TERMINAL: "..."}
676
+ for step in steps:
677
+ mark = marks.get(step.action, " ")
678
+ suffix = " [projected]" if step.projected else ""
679
+ lines.append(
680
+ f" {mark} {GATE_LABELS.get(step.gate, step.gate)}: "
681
+ f"{step.action.upper()} — {step.reason}{suffix}"
682
+ )
683
+
684
+ lines.append("")
685
+ lines.append("Work-order frontier (what the `work` gate would dispatch):")
686
+ if to_dispatch:
687
+ for pick in to_dispatch:
688
+ group = getattr(pick.entry, "group", None)
689
+ grp = f" (group {group})" if group else ""
690
+ lines.append(
691
+ f" would dispatch #{pick.entry.issue_number} -> "
692
+ f"{pick.machine.name}{grp}"
693
+ )
694
+ else:
695
+ lines.append(" (nothing ready to dispatch right now)")
696
+
697
+ if skipped:
698
+ lines.append(" Ready but no idle machine:")
699
+ for s in skipped:
700
+ lines.append(f" #{s.entry.issue_number}: {s.reason}")
701
+ if waiting:
702
+ lines.append(" Waiting on declared-order dependencies:")
703
+ for b in waiting:
704
+ lines.append(f" #{b.issue_number}: {b.reason}")
705
+
706
+ if footer:
707
+ lines.append("")
708
+ lines.append("(dry run — nothing dispatched, no gate record written)")
709
+ return lines