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,2081 @@
1
+ """`coord milestone` — Phase 0 (`order`) + Phase 1 (`dispatch`) of #767
2
+ (milestone-driven workflow).
3
+
4
+ Thin CLI glue around ``coord/milestone_order.py`` (the pure DAG/frontier
5
+ parser) and ``coord/milestone_dispatch.py`` (Phase 1's machine-picking +
6
+ dispatch): fetches the tracking issue + milestone membership + issue
7
+ terminal state from GitHub via ``coord.milestone_dispatch.
8
+ fetch_milestone_context`` (shared by both subcommands so they see identical
9
+ inputs), then hands that data to the pure functions and prints the result.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import click
19
+ import httpx
20
+
21
+ from coord.commands._common import _CONFIG_OPTION, _load_config
22
+ from coord.milestone_dispatch import (
23
+ DispatchOutcome,
24
+ MachinePick,
25
+ MilestoneDispatchError,
26
+ dispatch_entry,
27
+ fetch_milestone_context,
28
+ gate_a_status,
29
+ is_milestone_complete,
30
+ pick_machine,
31
+ plan_dispatch,
32
+ )
33
+ from coord.milestone_order import (
34
+ TRACKING_ISSUE_LABEL,
35
+ WorkOrder,
36
+ WorkOrderError,
37
+ WorkOrderNode,
38
+ compute_progress,
39
+ parse_progress,
40
+ parse_sub_issues,
41
+ parse_work_order,
42
+ remove_sub_issues_section,
43
+ render_progress,
44
+ render_sub_issues,
45
+ render_work_order,
46
+ replace_progress_section,
47
+ replace_sub_issues_section,
48
+ replace_work_order_section,
49
+ validate_milestone_membership,
50
+ )
51
+
52
+
53
+ @click.group("milestone")
54
+ def milestone_group() -> None:
55
+ """Milestone work-order operations (#767 Phase 0) + the milestone write
56
+ seam (#645)."""
57
+
58
+
59
+ @milestone_group.command(
60
+ "create",
61
+ help=(
62
+ "Create a GitHub milestone through the backend-agnostic tracker seam. "
63
+ "REPO is the local repo name from coordinator.yml. Prints the new "
64
+ "milestone number on success. Routes through the daemon seam so "
65
+ "agents (notably the milestone-chat session) never call `gh` "
66
+ "directly."
67
+ ),
68
+ )
69
+ @click.argument("repo")
70
+ @click.option("--title", required=True, help="Milestone title.")
71
+ @click.option("--description", default=None, help="Milestone description (markdown).")
72
+ @click.option(
73
+ "--due-on",
74
+ default=None,
75
+ help="Due date, ISO 8601 (e.g. 2026-08-01T00:00:00Z).",
76
+ )
77
+ @_CONFIG_OPTION
78
+ def milestone_create_cmd(
79
+ repo: str,
80
+ title: str,
81
+ description: str | None,
82
+ due_on: str | None,
83
+ config_path: Path,
84
+ ) -> None:
85
+ cfg = _load_config(config_path)
86
+ repo_entry = cfg.repo(repo)
87
+ if repo_entry is None:
88
+ click.echo(f"error: unknown repo {repo!r}", err=True)
89
+ sys.exit(2)
90
+
91
+ from coord.state import write_milestone
92
+
93
+ try:
94
+ result = write_milestone(
95
+ repo,
96
+ title=title,
97
+ description=description,
98
+ due_on=due_on,
99
+ repo_github=repo_entry.github,
100
+ )
101
+ except Exception as e: # noqa: BLE001
102
+ click.echo(f"error: milestone create failed: {e}", err=True)
103
+ sys.exit(1)
104
+ click.echo(
105
+ f"milestone #{result.get('number')} created ({repo_entry.github}): "
106
+ f"{result.get('title')}"
107
+ )
108
+
109
+
110
+ @milestone_group.command(
111
+ "edit",
112
+ help=(
113
+ "Edit a GitHub milestone's title/description/due date. REPO is the "
114
+ "local repo name from coordinator.yml; NUMBER is the GH milestone "
115
+ "number. Provide at least one of --title/--description/--due-on. "
116
+ "Routes through the backend-agnostic tracker seam."
117
+ ),
118
+ )
119
+ @click.argument("repo")
120
+ @click.argument("number", type=int)
121
+ @click.option("--title", default=None, help="New milestone title.")
122
+ @click.option("--description", default=None, help="New milestone description (markdown).")
123
+ @click.option(
124
+ "--due-on",
125
+ default=None,
126
+ help="New due date, ISO 8601 (e.g. 2026-08-01T00:00:00Z).",
127
+ )
128
+ @_CONFIG_OPTION
129
+ def milestone_edit_cmd(
130
+ repo: str,
131
+ number: int,
132
+ title: str | None,
133
+ description: str | None,
134
+ due_on: str | None,
135
+ config_path: Path,
136
+ ) -> None:
137
+ cfg = _load_config(config_path)
138
+ repo_entry = cfg.repo(repo)
139
+ if repo_entry is None:
140
+ click.echo(f"error: unknown repo {repo!r}", err=True)
141
+ sys.exit(2)
142
+ if title is None and description is None and due_on is None:
143
+ click.echo(
144
+ "error: provide --title and/or --description and/or --due-on",
145
+ err=True,
146
+ )
147
+ sys.exit(2)
148
+
149
+ from coord.state import write_milestone
150
+
151
+ try:
152
+ write_milestone(
153
+ repo,
154
+ number=number,
155
+ title=title,
156
+ description=description,
157
+ due_on=due_on,
158
+ repo_github=repo_entry.github,
159
+ )
160
+ except Exception as e: # noqa: BLE001
161
+ click.echo(f"error: milestone edit failed: {e}", err=True)
162
+ sys.exit(1)
163
+ click.echo(f"milestone #{number} updated ({repo_entry.github})")
164
+
165
+
166
+ @milestone_group.command(
167
+ "assign",
168
+ help=(
169
+ "Assign an existing issue to a milestone. REPO is the local repo name "
170
+ "from coordinator.yml; ISSUE is the GH issue number; MILESTONE is the "
171
+ "milestone number (e.g. `5`) or title (e.g. `v1.0`). Titles are "
172
+ "resolved to their number by fetching open milestones from GitHub.\n\n"
173
+ "Routes through the daemon seam (``POST /issue-milestone``) so the "
174
+ "write is always traceable and the local issues cache "
175
+ "``milestone_number``/``milestone_title`` columns are updated "
176
+ "immediately (no need to wait for ``coord sync``)."
177
+ ),
178
+ )
179
+ @click.argument("repo")
180
+ @click.argument("issue", type=int)
181
+ @click.argument("milestone")
182
+ @_CONFIG_OPTION
183
+ def milestone_assign_cmd(
184
+ repo: str,
185
+ issue: int,
186
+ milestone: str,
187
+ config_path: Path,
188
+ ) -> None:
189
+ cfg = _load_config(config_path)
190
+ repo_entry = cfg.repo(repo)
191
+ if repo_entry is None:
192
+ click.echo(f"error: unknown repo {repo!r}", err=True)
193
+ sys.exit(2)
194
+
195
+ from coord import github_ops # noqa: PLC0415
196
+ from coord.state import assign_issue_milestone # noqa: PLC0415
197
+
198
+ # Resolve milestone argument: numeric → number, string → title lookup.
199
+ milestone_number: int
200
+ milestone_title: str | None
201
+
202
+ try:
203
+ milestone_number = int(milestone)
204
+ # Resolve the title from the number so the cache can store it.
205
+ try:
206
+ ms_data = github_ops.get_milestone(repo_entry.github, milestone_number)
207
+ milestone_title = ms_data.get("title")
208
+ except RuntimeError as e:
209
+ click.echo(
210
+ f"error: could not fetch milestone #{milestone_number}: {e}", err=True
211
+ )
212
+ sys.exit(1)
213
+ except ValueError:
214
+ # Treat the argument as a title and resolve to a number.
215
+ try:
216
+ all_ms = github_ops.get_repo_milestones(repo_entry.github)
217
+ except RuntimeError as e:
218
+ click.echo(f"error: could not list milestones: {e}", err=True)
219
+ sys.exit(1)
220
+ matches = [m for m in all_ms if m.get("title") == milestone]
221
+ if not matches:
222
+ click.echo(
223
+ f"error: no open milestone with title {milestone!r} in {repo_entry.github}",
224
+ err=True,
225
+ )
226
+ sys.exit(1)
227
+ if len(matches) > 1:
228
+ click.echo(
229
+ f"error: multiple open milestones match {milestone!r} — "
230
+ "use the milestone number instead",
231
+ err=True,
232
+ )
233
+ sys.exit(1)
234
+ milestone_number = matches[0]["number"]
235
+ milestone_title = matches[0].get("title")
236
+
237
+ try:
238
+ assign_issue_milestone(
239
+ repo,
240
+ issue,
241
+ milestone_number,
242
+ milestone_title=milestone_title,
243
+ repo_github=repo_entry.github,
244
+ )
245
+ except Exception as e: # noqa: BLE001
246
+ click.echo(f"error: milestone assign failed: {e}", err=True)
247
+ sys.exit(1)
248
+
249
+ ms_label = (
250
+ f"{milestone_title!r} (#{milestone_number})"
251
+ if milestone_title
252
+ else f"#{milestone_number}"
253
+ )
254
+ click.echo(f"#{issue} ({repo_entry.github}) assigned to milestone {ms_label}")
255
+
256
+
257
+ @milestone_group.command(
258
+ "remove",
259
+ help=(
260
+ "Unassign an issue from its milestone (#1003) — the counterpart to "
261
+ "`coord milestone assign`. REPO is the local repo name from "
262
+ "coordinator.yml; ISSUE is the GH issue number. Idempotent — "
263
+ "clearing an issue that has no milestone is a no-op. Routes through "
264
+ "the daemon seam (``POST /issue-milestone-remove``) so the local "
265
+ "issues cache ``milestone_number``/``milestone_title`` columns are "
266
+ "cleared immediately (no need to wait for ``coord sync``)."
267
+ ),
268
+ )
269
+ @click.argument("repo")
270
+ @click.argument("issue", type=int)
271
+ @_CONFIG_OPTION
272
+ def milestone_remove_cmd(
273
+ repo: str,
274
+ issue: int,
275
+ config_path: Path,
276
+ ) -> None:
277
+ cfg = _load_config(config_path)
278
+ repo_entry = cfg.repo(repo)
279
+ if repo_entry is None:
280
+ click.echo(f"error: unknown repo {repo!r}", err=True)
281
+ sys.exit(2)
282
+
283
+ from coord.state import unassign_issue_milestone # noqa: PLC0415
284
+
285
+ try:
286
+ unassign_issue_milestone(repo, issue, repo_github=repo_entry.github)
287
+ except Exception as e: # noqa: BLE001
288
+ click.echo(f"error: milestone remove failed: {e}", err=True)
289
+ sys.exit(1)
290
+
291
+ click.echo(f"#{issue} ({repo_entry.github}) removed from its milestone")
292
+
293
+
294
+ @milestone_group.command(
295
+ "add-child",
296
+ help=(
297
+ "Idempotent append/remove on an epic tracking issue's `## "
298
+ "Sub-issues` checklist (#1008) — same splice-not-duplicate spirit "
299
+ "as `coord milestone write-order` for `## Work order`, keyed on a "
300
+ "different heading. REPO is the local repo name from "
301
+ "coordinator.yml; EPIC is the GH issue number of the epic tracking "
302
+ "issue; ISSUE is the child issue number to add (or remove, with "
303
+ "--remove).\n\n"
304
+ "Adding an ISSUE already present with identical --group/--after "
305
+ "is a no-op; adding it again with different annotations updates "
306
+ "that line in place (preserving its `[x]` checked state). "
307
+ "--remove drops ISSUE from the checklist (no-op if absent) and "
308
+ "cannot be combined with --group/--after. The resulting checklist "
309
+ "is re-parsed and validated (no duplicate/undeclared-`after`/cycle) "
310
+ "before writing via `github_ops.update_issue_body` — the epic's "
311
+ "`## Work order` section (if any) is left untouched."
312
+ ),
313
+ )
314
+ @click.argument("repo")
315
+ @click.argument("epic", type=int)
316
+ @click.argument("issue", type=int)
317
+ @click.option(
318
+ "--group", default=None, help="Optional `{group: G}` annotation for ISSUE."
319
+ )
320
+ @click.option(
321
+ "--after", "after_raw", default=None,
322
+ help="Optional `{after: N,...}` annotation — comma-separated issue numbers.",
323
+ )
324
+ @click.option(
325
+ "--remove", is_flag=True,
326
+ help="Remove ISSUE from the epic's `## Sub-issues` checklist instead of adding it.",
327
+ )
328
+ @_CONFIG_OPTION
329
+ def milestone_add_child_cmd(
330
+ repo: str,
331
+ epic: int,
332
+ issue: int,
333
+ group: str | None,
334
+ after_raw: str | None,
335
+ remove: bool,
336
+ config_path: Path,
337
+ ) -> None:
338
+ cfg = _load_config(config_path)
339
+ repo_entry = cfg.repo(repo)
340
+ if repo_entry is None:
341
+ click.echo(f"error: unknown repo {repo!r}", err=True)
342
+ sys.exit(2)
343
+
344
+ if remove and (group is not None or after_raw is not None):
345
+ click.echo("error: --remove cannot be combined with --group/--after", err=True)
346
+ sys.exit(2)
347
+
348
+ after: tuple[int, ...] = ()
349
+ if after_raw:
350
+ try:
351
+ after = tuple(
352
+ int(chunk.strip().lstrip("#"))
353
+ for chunk in after_raw.split(",")
354
+ if chunk.strip()
355
+ )
356
+ except ValueError:
357
+ click.echo(
358
+ f"error: --after must be a comma-separated list of issue "
359
+ f"numbers, got {after_raw!r}",
360
+ err=True,
361
+ )
362
+ sys.exit(2)
363
+
364
+ from coord import github_ops # noqa: PLC0415
365
+
366
+ try:
367
+ epic_data = github_ops.get_issue(repo_entry.github, epic)
368
+ except RuntimeError as e:
369
+ click.echo(f"error: could not fetch epic #{epic}: {e}", err=True)
370
+ sys.exit(1)
371
+
372
+ if not remove:
373
+ # Catch a typo'd child issue number before it's baked into the
374
+ # epic's body — mirrors write-order's pre-write validation rigor.
375
+ try:
376
+ github_ops.get_issue(repo_entry.github, issue)
377
+ except RuntimeError as e:
378
+ click.echo(f"error: could not fetch issue #{issue}: {e}", err=True)
379
+ sys.exit(1)
380
+
381
+ old_body = epic_data.get("body") or ""
382
+ try:
383
+ current = parse_sub_issues(old_body)
384
+ except WorkOrderError as e:
385
+ click.echo(f"error: existing `## Sub-issues` block is invalid: {e}", err=True)
386
+ sys.exit(1)
387
+
388
+ nodes = list(current.nodes)
389
+ existing_idx = next(
390
+ (i for i, n in enumerate(nodes) if n.issue_number == issue), None
391
+ )
392
+
393
+ if remove:
394
+ if existing_idx is None:
395
+ click.echo(
396
+ f"#{epic} ({repo_entry.github}): #{issue} is not in the "
397
+ "`## Sub-issues` checklist (no-op)"
398
+ )
399
+ return
400
+ nodes.pop(existing_idx)
401
+ else:
402
+ prior_checked = (
403
+ nodes[existing_idx].checked if existing_idx is not None else False
404
+ )
405
+ candidate_node = WorkOrderNode(
406
+ issue_number=issue, group=group, after=after, checked=prior_checked
407
+ )
408
+ if existing_idx is not None:
409
+ if nodes[existing_idx] == candidate_node:
410
+ click.echo(
411
+ f"#{epic} ({repo_entry.github}): #{issue} already in the "
412
+ "`## Sub-issues` checklist (no-op)"
413
+ )
414
+ return
415
+ nodes[existing_idx] = candidate_node
416
+ else:
417
+ nodes.append(candidate_node)
418
+
419
+ new_block = render_sub_issues(WorkOrder(nodes=tuple(nodes)))
420
+ candidate_body = replace_sub_issues_section(old_body, new_block)
421
+
422
+ try:
423
+ parse_sub_issues(candidate_body)
424
+ except WorkOrderError as e:
425
+ click.echo(
426
+ f"error: resulting `## Sub-issues` block would be invalid: {e}", err=True
427
+ )
428
+ sys.exit(1)
429
+
430
+ if candidate_body == old_body:
431
+ click.echo(
432
+ f"#{epic} ({repo_entry.github}): `## Sub-issues` unchanged "
433
+ "(idempotent no-op)"
434
+ )
435
+ return
436
+
437
+ github_ops.update_issue_body(repo_entry.github, epic, candidate_body)
438
+ action = "removed from" if remove else "added to"
439
+ click.echo(
440
+ f"#{issue} {action} #{epic}'s ({repo_entry.github}) `## Sub-issues` checklist"
441
+ )
442
+
443
+
444
+ @milestone_group.command(
445
+ "sync",
446
+ help=(
447
+ "#1061 (EP-2): backfill the live GitHub sub-issues API from an "
448
+ "epic's `## Work order` and/or `## Sub-issues` blocks, and retire "
449
+ "the old checkbox-based grammar on that body. REPO is the local "
450
+ "repo name from coordinator.yml; EPIC is the GH issue number of "
451
+ "the epic tracking issue.\n\n"
452
+ "`## Work order` and `## Sub-issues` are independent, "
453
+ "not-necessarily-overlapping sources for the same parent->children "
454
+ "relationship (a standalone `add-child` epic may have only a `## "
455
+ "Sub-issues` checklist) — sync unions the two before acting, so a "
456
+ "child listed in either section is never silently dropped. For "
457
+ "every issue in that union, links it as a live sub-issue of EPIC "
458
+ "(skipping any already linked — idempotent, safe to re-run). Then "
459
+ "rewrites `## Work order` to the checkbox-free grammar (`- #N "
460
+ "{group: ..., after: ...}`, no `[ ]`/`[x]` — the box was parsed and "
461
+ "rendered but never read for readiness), folding in any `## "
462
+ "Sub-issues`-only children, and removes the `## Sub-issues` block "
463
+ "entirely — membership is now owned by the live API plus `## Work "
464
+ "order`, so the separate checklist (#1008) is pure duplication. A "
465
+ "body already in the target shape with nothing left to link is "
466
+ "reported unchanged, not rewritten."
467
+ ),
468
+ )
469
+ @click.argument("repo")
470
+ @click.argument("epic", type=int)
471
+ @click.option(
472
+ "--dry-run", is_flag=True,
473
+ help="Show what would be linked/rewritten without calling GitHub.",
474
+ )
475
+ @_CONFIG_OPTION
476
+ def milestone_sync_cmd(repo: str, epic: int, dry_run: bool, config_path: Path) -> None:
477
+ cfg = _load_config(config_path)
478
+ repo_entry = cfg.repo(repo)
479
+ if repo_entry is None:
480
+ click.echo(f"error: unknown repo {repo!r}", err=True)
481
+ sys.exit(2)
482
+
483
+ from coord import github_ops
484
+ from coord.parentage_github import GitHubParentage
485
+
486
+ try:
487
+ epic_data = github_ops.get_issue(repo_entry.github, epic)
488
+ except RuntimeError as e:
489
+ click.echo(f"error: could not fetch epic #{epic}: {e}", err=True)
490
+ sys.exit(1)
491
+
492
+ old_body = epic_data.get("body") or ""
493
+ try:
494
+ work_order = parse_work_order(old_body)
495
+ except WorkOrderError as e:
496
+ click.echo(f"error: #{epic}'s `## Work order` block is invalid: {e}", err=True)
497
+ sys.exit(1)
498
+ try:
499
+ sub_issues = parse_sub_issues(old_body)
500
+ except WorkOrderError as e:
501
+ click.echo(f"error: #{epic}'s `## Sub-issues` block is invalid: {e}", err=True)
502
+ sys.exit(1)
503
+
504
+ if not work_order.nodes and not sub_issues.nodes:
505
+ click.echo(
506
+ f"#{epic} ({repo_entry.github}): no `## Work order` or `## "
507
+ "Sub-issues` block found — nothing to sync"
508
+ )
509
+ return
510
+
511
+ # #1061 fix-iteration 1: `## Work order` and `## Sub-issues` are
512
+ # independent, not-necessarily-overlapping sources for the same
513
+ # parent->children relationship (mirrors the `fallback_to_work_order`
514
+ # precedent added to `coord/parentage.py` by #1197) — a standalone
515
+ # `add-child` epic can have only a `## Sub-issues` checklist, and either
516
+ # section can list a child the other doesn't. Union them before deciding
517
+ # what to link/render so neither case silently drops a child; a `##
518
+ # Sub-issues`-only node is folded into the rewritten `## Work order`
519
+ # (preferred per the issue's resolution: `## Work order` + the live API
520
+ # own membership going forward) rather than merely linked and discarded.
521
+ extra_from_sub_issues = [
522
+ n for n in sub_issues.nodes
523
+ if n.issue_number not in work_order.issue_numbers
524
+ ]
525
+ if extra_from_sub_issues:
526
+ merged_block = render_work_order(
527
+ WorkOrder(nodes=tuple(work_order.nodes) + tuple(extra_from_sub_issues)),
528
+ checkbox=False,
529
+ )
530
+ try:
531
+ work_order = parse_work_order(f"## Work order\n{merged_block}")
532
+ except WorkOrderError as e:
533
+ click.echo(
534
+ f"error: #{epic}'s `## Work order` and `## Sub-issues` "
535
+ f"blocks conflict when merged: {e}",
536
+ err=True,
537
+ )
538
+ sys.exit(1)
539
+
540
+ parentage = GitHubParentage()
541
+ try:
542
+ already_linked = {c.number for c in parentage.children(repo_entry.github, epic)}
543
+ except RuntimeError as e:
544
+ click.echo(
545
+ f"error: could not fetch #{epic}'s live sub-issues: {e}", err=True
546
+ )
547
+ sys.exit(1)
548
+
549
+ to_link = [n.issue_number for n in work_order.nodes if n.issue_number not in already_linked]
550
+
551
+ new_block = render_work_order(work_order, checkbox=False)
552
+ candidate_body = replace_work_order_section(old_body, new_block)
553
+ stripped_body = remove_sub_issues_section(candidate_body)
554
+ had_sub_issues = stripped_body != candidate_body
555
+ candidate_body = stripped_body
556
+ body_changed = candidate_body != old_body
557
+
558
+ node_source = (
559
+ "`## Work order` + `## Sub-issues`" if extra_from_sub_issues else "`## Work order`"
560
+ )
561
+ click.echo(
562
+ f"#{epic} ({repo_entry.github}): {len(work_order.nodes)} node(s) in "
563
+ f"{node_source}"
564
+ )
565
+ if to_link:
566
+ verb = "would link" if dry_run else "linking"
567
+ click.echo(f" {verb} as live sub-issues: " + ", ".join(f"#{n}" for n in to_link))
568
+ else:
569
+ click.echo(" live sub-issues API already up to date (no-op)")
570
+
571
+ if body_changed:
572
+ bits = ["checkbox-free `## Work order`"]
573
+ if extra_from_sub_issues:
574
+ bits.append(
575
+ "folded "
576
+ + ", ".join(f"#{n.issue_number}" for n in extra_from_sub_issues)
577
+ + " in from `## Sub-issues`"
578
+ )
579
+ if had_sub_issues:
580
+ bits.append("removed `## Sub-issues`")
581
+ verb = "would rewrite" if dry_run else "rewriting"
582
+ click.echo(f" {verb} body ({', '.join(bits)})")
583
+ else:
584
+ click.echo(" body already in the target shape (no-op)")
585
+
586
+ if dry_run:
587
+ click.echo("(dry run — nothing changed)")
588
+ return
589
+
590
+ failed: list[tuple[int, str]] = []
591
+ for issue_number in to_link:
592
+ try:
593
+ parentage.add_child(repo_entry.github, epic, issue_number)
594
+ except RuntimeError as e:
595
+ failed.append((issue_number, str(e)))
596
+
597
+ for issue_number, err in failed:
598
+ click.echo(
599
+ f" error: could not link #{issue_number} as a sub-issue of #{epic}: {err}",
600
+ err=True,
601
+ )
602
+
603
+ if body_changed:
604
+ github_ops.update_issue_body(repo_entry.github, epic, candidate_body)
605
+
606
+ if failed:
607
+ sys.exit(1)
608
+
609
+
610
+ @milestone_group.command(
611
+ "sync-progress",
612
+ help=(
613
+ "#1412 deliverable 2: render live per-item status into a separate "
614
+ "`## Progress` section of an epic's tracking-issue body — `## Work "
615
+ "order` (and, transitionally, `## Sub-issues`) are never touched. "
616
+ "REPO is the local repo name from coordinator.yml; TRACKING_ISSUE "
617
+ "is the GH issue number of the tracking issue.\n\n"
618
+ "Derives each node's status from exactly the same live inputs "
619
+ "`coord milestone order` already prints (terminal state + the "
620
+ "current ready frontier) — never a second notion of readiness. "
621
+ "Idempotent: a fresh run whose computed statuses match what's "
622
+ "already in `## Progress` is a no-op (not even the timestamp is "
623
+ "touched), so this is safe to run on a timer — the daemon tick "
624
+ "does exactly that for every milestone registered via a "
625
+ "non-dry-run `coord milestone dispatch`, regardless of whether "
626
+ "`milestone.auto_dispatch` is enabled (this command dispatches "
627
+ "nothing, so there's no reason to gate it on that flag)."
628
+ ),
629
+ )
630
+ @click.argument("repo")
631
+ @click.argument("tracking_issue", type=int)
632
+ @click.option(
633
+ "--dry-run", is_flag=True,
634
+ help="Show the computed status without writing anything.",
635
+ )
636
+ @_CONFIG_OPTION
637
+ def milestone_sync_progress_cmd(
638
+ repo: str, tracking_issue: int, dry_run: bool, config_path: Path
639
+ ) -> None:
640
+ cfg = _load_config(config_path)
641
+ repo_entry = cfg.repo(repo)
642
+ if repo_entry is None:
643
+ click.echo(f"error: unknown repo {repo!r}", err=True)
644
+ sys.exit(2)
645
+
646
+ from coord import board_service, github_ops
647
+ from coord.milestone_order import ready_frontier
648
+
649
+ try:
650
+ ctx = fetch_milestone_context(repo_entry, tracking_issue)
651
+ except MilestoneDispatchError as e:
652
+ click.echo(f"error: {e}", err=True)
653
+ sys.exit(1)
654
+
655
+ if not ctx.work_order.nodes:
656
+ click.echo(
657
+ f"#{tracking_issue}: no `## Work order` block found — nothing to "
658
+ "report progress on"
659
+ )
660
+ return
661
+
662
+ board = board_service.read_board()
663
+ frontier = ready_frontier(
664
+ ctx.work_order,
665
+ board,
666
+ repo_name=repo_entry.name,
667
+ repo_github=repo_entry.github,
668
+ terminal_issues=set(ctx.terminal_issues),
669
+ )
670
+ statuses = compute_progress(ctx.work_order, frontier, ctx.terminal_issues)
671
+
672
+ try:
673
+ issue_data = github_ops.get_issue(repo_entry.github, tracking_issue)
674
+ except RuntimeError as e:
675
+ click.echo(f"error: could not fetch #{tracking_issue}: {e}", err=True)
676
+ sys.exit(1)
677
+ old_body = issue_data.get("body") or ""
678
+
679
+ click.echo(
680
+ f"Progress for #{tracking_issue} (milestone #{ctx.milestone_number}):"
681
+ )
682
+ for s in statuses:
683
+ bits = [f"[{s.status}]", f"#{s.issue_number}"]
684
+ if s.group:
685
+ bits.append(f"(group {s.group})")
686
+ line = " " + " ".join(bits)
687
+ if s.detail:
688
+ line += f" — {s.detail}"
689
+ click.echo(line)
690
+
691
+ if parse_progress(old_body) == statuses:
692
+ click.echo("`## Progress` already up to date (no-op)")
693
+ return
694
+
695
+ if dry_run:
696
+ click.echo("(dry run — would update `## Progress`)")
697
+ return
698
+
699
+ from datetime import datetime, timezone
700
+
701
+ generated_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
702
+ new_block = render_progress(statuses, generated_at=generated_at)
703
+ candidate_body = replace_progress_section(old_body, new_block)
704
+ github_ops.update_issue_body(repo_entry.github, tracking_issue, candidate_body)
705
+ click.echo(f"#{tracking_issue}: updated `## Progress` ({len(statuses)} item(s))")
706
+
707
+
708
+ _DEFAULT_CAPTURE_BODY = (
709
+ "Captured via coord-tui fast plan capture — no work order yet. "
710
+ "Promote to a full epic with `coord milestone chat`."
711
+ )
712
+
713
+
714
+ @milestone_group.command(
715
+ "capture",
716
+ help=(
717
+ "Fast-capture a lightweight plan stub (#977): creates a new milestone "
718
+ "titled TEXT, a plain issue (no `epic` label) under it, and assigns "
719
+ "the issue to the milestone — all through the existing write seams "
720
+ "(`coord milestone create` + `coord issue create` + `coord milestone "
721
+ "assign` composed in one step). REPO is the local repo name from "
722
+ "coordinator.yml. Appears immediately in the `coord plans` / TUI "
723
+ "Plans-panel roster flagged `no_work_order`, ready to be promoted to "
724
+ "a full epic later via `coord milestone chat`."
725
+ ),
726
+ )
727
+ @click.argument("repo")
728
+ @click.option(
729
+ "--title",
730
+ required=True,
731
+ help="Plan title (used for both the milestone and the issue).",
732
+ )
733
+ @click.option(
734
+ "--body",
735
+ default=None,
736
+ help="Issue body (markdown). Defaults to a short note pointing at `coord milestone chat`.",
737
+ )
738
+ @_CONFIG_OPTION
739
+ def milestone_capture_cmd(
740
+ repo: str,
741
+ title: str,
742
+ body: str | None,
743
+ config_path: Path,
744
+ ) -> None:
745
+ cfg = _load_config(config_path)
746
+ repo_entry = cfg.repo(repo)
747
+ if repo_entry is None:
748
+ click.echo(f"error: unknown repo {repo!r}", err=True)
749
+ sys.exit(2)
750
+ slug = repo_entry.github
751
+
752
+ from coord.state import assign_issue_milestone, create_issue, write_milestone # noqa: PLC0415
753
+
754
+ try:
755
+ ms = write_milestone(repo, title=title, repo_github=slug)
756
+ except Exception as e: # noqa: BLE001
757
+ click.echo(f"error: plan capture failed creating milestone: {e}", err=True)
758
+ sys.exit(1)
759
+
760
+ try:
761
+ issue = create_issue(repo, title, body or _DEFAULT_CAPTURE_BODY, repo_github=slug)
762
+ except Exception as e: # noqa: BLE001
763
+ click.echo(
764
+ f"error: plan capture failed creating issue (milestone #{ms.get('number')} "
765
+ f"was already created): {e}",
766
+ err=True,
767
+ )
768
+ sys.exit(1)
769
+
770
+ try:
771
+ assign_issue_milestone(
772
+ repo,
773
+ issue["number"],
774
+ ms["number"],
775
+ milestone_title=title,
776
+ repo_github=slug,
777
+ )
778
+ except Exception as e: # noqa: BLE001
779
+ click.echo(
780
+ f"error: plan capture failed assigning issue #{issue.get('number')} to "
781
+ f"milestone #{ms.get('number')}: {e}",
782
+ err=True,
783
+ )
784
+ sys.exit(1)
785
+
786
+ click.echo(
787
+ f"plan captured: milestone #{ms['number']} ({slug}) — issue #{issue['number']} "
788
+ "— no work order yet. Promote via `coord milestone chat`."
789
+ )
790
+
791
+
792
+ @milestone_group.command(
793
+ "order",
794
+ help=(
795
+ "Parse the `## Work order` block from a milestone tracking issue and "
796
+ "print the DAG + current ready frontier. REPO is the local repo name "
797
+ "from coordinator.yml; TRACKING_ISSUE is the GH issue number of the "
798
+ "tracking issue (its body holds the `## Work order` block)."
799
+ ),
800
+ )
801
+ @click.argument("repo")
802
+ @click.argument("tracking_issue", type=int)
803
+ @_CONFIG_OPTION
804
+ def milestone_order_cmd(repo: str, tracking_issue: int, config_path: Path) -> None:
805
+ cfg = _load_config(config_path)
806
+ repo_entry = cfg.repo(repo)
807
+ if repo_entry is None:
808
+ click.echo(f"error: unknown repo {repo!r}", err=True)
809
+ sys.exit(2)
810
+
811
+ from coord import board_service
812
+
813
+ try:
814
+ ctx = fetch_milestone_context(repo_entry, tracking_issue)
815
+ except MilestoneDispatchError as e:
816
+ click.echo(f"error: {e}", err=True)
817
+ sys.exit(1)
818
+
819
+ if not ctx.work_order.nodes:
820
+ click.echo(f"#{tracking_issue}: no `## Work order` block found")
821
+ return
822
+
823
+ board = board_service.read_board()
824
+ from coord.milestone_order import ready_frontier
825
+
826
+ frontier = ready_frontier(
827
+ ctx.work_order,
828
+ board,
829
+ repo_name=repo_entry.name,
830
+ repo_github=repo_entry.github,
831
+ terminal_issues=set(ctx.terminal_issues),
832
+ )
833
+
834
+ click.echo(
835
+ f"Work order for #{tracking_issue} (milestone #{ctx.milestone_number}):"
836
+ )
837
+ for node in ctx.work_order.nodes:
838
+ state = "done" if node.issue_number in ctx.terminal_issues else "open"
839
+ bits = [f"[{state}]"]
840
+ if node.group:
841
+ bits.append(f"group:{node.group}")
842
+ if node.after:
843
+ bits.append("after:" + ",".join(f"#{d}" for d in node.after))
844
+ click.echo(f" #{node.issue_number} {' '.join(bits)}")
845
+
846
+ click.echo()
847
+ click.echo("Ready frontier:")
848
+ if frontier.ready:
849
+ for entry in frontier.ready:
850
+ suffix = f" (group {entry.group})" if entry.group else ""
851
+ click.echo(f" #{entry.issue_number}{suffix}")
852
+ else:
853
+ click.echo(" (none)")
854
+
855
+ if frontier.blocked:
856
+ click.echo()
857
+ click.echo("Blocked:")
858
+ for b in frontier.blocked:
859
+ click.echo(f" #{b.issue_number}: {b.reason}")
860
+
861
+
862
+ def _echo_pick(pick: MachinePick) -> None:
863
+ suffix = f" (group {pick.entry.group})" if pick.entry.group else ""
864
+ click.echo(f" #{pick.entry.issue_number} -> {pick.machine.name}{suffix}")
865
+
866
+
867
+ def _echo_outcome(outcome: DispatchOutcome) -> None:
868
+ if outcome.ok:
869
+ click.echo(
870
+ f" #{outcome.issue_number} -> {outcome.machine_name} "
871
+ f"(dispatched, assignment {outcome.assignment_id})"
872
+ )
873
+ if outcome.model_reason:
874
+ click.echo(f" model: {outcome.model_reason}")
875
+ if outcome.provider_reason:
876
+ click.echo(f" provider: {outcome.provider_reason}")
877
+ else:
878
+ click.echo(
879
+ f" #{outcome.issue_number}: dispatch failed: {outcome.error}", err=True
880
+ )
881
+
882
+
883
+ @milestone_group.command(
884
+ "dispatch",
885
+ help=(
886
+ "Promote a milestone into the pipeline: dispatch its ready frontier "
887
+ "in parallel (up to idle/capable machines), then keep draining it as "
888
+ "declared-order dependencies complete. REPO is the local repo name "
889
+ "from coordinator.yml; TRACKING_ISSUE is the GH issue number of the "
890
+ "tracking issue (its body holds the `## Work order` block). This is "
891
+ "the single explicit approval for the whole declared work order — "
892
+ "it does not expand scope beyond what `## Work order` lists."
893
+ ),
894
+ )
895
+ @click.argument("repo")
896
+ @click.argument("tracking_issue", type=int)
897
+ @click.option(
898
+ "--dry-run", is_flag=True,
899
+ help="Show what would dispatch now vs. what's waiting (and on what), without dispatching.",
900
+ )
901
+ @click.option(
902
+ "--next", "next_", is_flag=True,
903
+ help=(
904
+ "Single-pick mode: show up to 3 ready-frontier items and dispatch "
905
+ "only the one you choose. Lighter-weight than draining the whole "
906
+ "milestone — does not register for daemon auto-drain."
907
+ ),
908
+ )
909
+ @click.option(
910
+ "--pick", "pick_issue", type=int, default=None,
911
+ help=(
912
+ "#1003: non-interactive companion to --next — dispatch this "
913
+ "specific ready-frontier issue number without the interactive "
914
+ "prompt (the coord-tui Plans-panel/MilestoneDag \"Dispatch next…\" "
915
+ "action's backend; a bare TTY-less `--next` would otherwise hang "
916
+ "on `click.prompt`). Errors if the issue isn't currently in the "
917
+ "ready-to-dispatch frontier."
918
+ ),
919
+ )
920
+ @_CONFIG_OPTION
921
+ def milestone_dispatch_cmd(
922
+ repo: str,
923
+ tracking_issue: int,
924
+ dry_run: bool,
925
+ next_: bool,
926
+ pick_issue: int | None,
927
+ config_path: Path,
928
+ ) -> None:
929
+ if pick_issue is not None and not next_:
930
+ click.echo("error: --pick requires --next", err=True)
931
+ sys.exit(2)
932
+ cfg = _load_config(config_path)
933
+ repo_entry = cfg.repo(repo)
934
+ if repo_entry is None:
935
+ click.echo(f"error: unknown repo {repo!r}", err=True)
936
+ sys.exit(2)
937
+
938
+ from coord import board_service
939
+
940
+ # #1930 (epic #1440, S-2): a milestone under gate control (`coord
941
+ # milestone drive`) is drained EXCLUSIVELY by the daemon's gate tick, as
942
+ # its `work` state — see `coord.milestone_gate`'s module docstring. This
943
+ # manual command is the second dispatch path #1440 exists to close: an
944
+ # operator who runs it against a gate-controlled milestone would launch
945
+ # the same frontier the daemon is about to (or just did) launch, racing
946
+ # it rather than being refused or delegating to it. Checked before the
947
+ # GitHub fetch below (and before Gate A, `--dry-run`, `--next`) so the
948
+ # refusal costs nothing and can never be silently bypassed by a flag.
949
+ from coord import state as state_mod # noqa: PLC0415
950
+
951
+ try:
952
+ existing_gate = state_mod.get_milestone_gate(
953
+ repo_name=repo_entry.name, tracking_issue=tracking_issue
954
+ )
955
+ except Exception as e: # noqa: BLE001 — see coord.client.fetch_milestone_gate:
956
+ # a thin client that can't reach the daemon has no way to know
957
+ # whether this milestone is gate-controlled, so refuse rather than
958
+ # risk racing a gate tick it can't see (#1930, epic #1440) — the
959
+ # same "refuse, never race" posture as the guard below, just for
960
+ # "couldn't determine" instead of "confirmed gated".
961
+ click.echo(
962
+ f"error: could not check #{tracking_issue}'s gate status ({e}) "
963
+ "— refusing to dispatch rather than risk racing a gate tick "
964
+ "this client can't see. Retry once the daemon is reachable, or "
965
+ "run this on the daemon host directly.",
966
+ err=True,
967
+ )
968
+ sys.exit(1)
969
+ if existing_gate is not None:
970
+ from coord import milestone_gate as mg # noqa: PLC0415
971
+
972
+ gate_name = str(existing_gate.get("gate") or "")
973
+ gate_label = (
974
+ mg.GATE_LABELS.get(gate_name, gate_name) if gate_name else "an unknown gate"
975
+ )
976
+ click.echo(
977
+ f"error: #{tracking_issue} is under gate control, currently at "
978
+ f"{gate_label} — the daemon's gate tick is the sole driver of "
979
+ "this milestone's frontier (epic #1440). Running `coord "
980
+ "milestone dispatch` here would race it, not replace it. Use "
981
+ "`coord milestone drive --dry-run` to see the planned sequence, "
982
+ "or wait for the daemon to reach `work`.",
983
+ err=True,
984
+ )
985
+ sys.exit(1)
986
+
987
+ try:
988
+ ctx = fetch_milestone_context(repo_entry, tracking_issue)
989
+ except MilestoneDispatchError as e:
990
+ click.echo(f"error: {e}", err=True)
991
+ sys.exit(1)
992
+
993
+ # Gate A (#930, docs/ORACLE_LOOP.md): refuse to dispatch any of this
994
+ # milestone's issues until its black-box contract exists. Checked before
995
+ # everything else — including `--dry-run`/`--next` — so the gate is
996
+ # never silently bypassable.
997
+ block_reason = gate_a_status(repo_entry, cfg, ctx.milestone_number)
998
+ if block_reason:
999
+ click.echo(f"error: {block_reason}", err=True)
1000
+ sys.exit(1)
1001
+
1002
+ if not ctx.work_order.nodes:
1003
+ click.echo(f"#{tracking_issue}: no `## Work order` block found")
1004
+ return
1005
+
1006
+ board = board_service.read_board()
1007
+ plan = plan_dispatch(ctx.work_order, board, cfg, repo_entry, ctx.terminal_issues)
1008
+
1009
+ click.echo(
1010
+ f"Work order for #{tracking_issue} (milestone #{ctx.milestone_number}):"
1011
+ )
1012
+
1013
+ if next_:
1014
+ if not plan.to_dispatch:
1015
+ click.echo("No ready frontier item can dispatch right now.")
1016
+ if plan.skipped:
1017
+ click.echo("Ready but no idle machine:")
1018
+ for s in plan.skipped:
1019
+ click.echo(f" #{s.entry.issue_number}: {s.reason}")
1020
+ if plan.waiting:
1021
+ click.echo("Waiting:")
1022
+ for b in plan.waiting:
1023
+ click.echo(f" #{b.issue_number}: {b.reason}")
1024
+ return
1025
+
1026
+ # #1003: --pick short-circuits the interactive prompt entirely — the
1027
+ # operator (or, via coord-tui, a client that already knows the
1028
+ # issue number it wants) names the issue directly instead of
1029
+ # picking an index off a numbered list. Search the FULL
1030
+ # to-dispatch set, not just the top-3 `choices` shown to a human —
1031
+ # a non-interactive caller has no reason to be limited to the
1032
+ # truncated display.
1033
+ if pick_issue is not None:
1034
+ chosen = next(
1035
+ (p for p in plan.to_dispatch if p.entry.issue_number == pick_issue),
1036
+ None,
1037
+ )
1038
+ if chosen is None:
1039
+ click.echo(
1040
+ f"error: #{pick_issue} is not in the ready-to-dispatch "
1041
+ "frontier right now",
1042
+ err=True,
1043
+ )
1044
+ sys.exit(1)
1045
+ if dry_run:
1046
+ click.echo(
1047
+ f"(dry run — would dispatch #{pick_issue} -> {chosen.machine.name})"
1048
+ )
1049
+ return
1050
+ outcome = dispatch_entry(
1051
+ chosen, repo_entry, cfg, board, tracking_issue=tracking_issue
1052
+ )
1053
+ _echo_outcome(outcome)
1054
+ if not outcome.ok:
1055
+ sys.exit(1)
1056
+ return
1057
+
1058
+ choices = plan.to_dispatch[:3]
1059
+ click.echo("Ready frontier — pick one to dispatch:")
1060
+ for i, pick in enumerate(choices, 1):
1061
+ suffix = f" (group {pick.entry.group})" if pick.entry.group else ""
1062
+ click.echo(f" {i}. #{pick.entry.issue_number} -> {pick.machine.name}{suffix}")
1063
+
1064
+ if dry_run:
1065
+ click.echo("(dry run — not dispatched)")
1066
+ return
1067
+
1068
+ idx = click.prompt(
1069
+ "Pick one", type=click.IntRange(1, len(choices)), default=1
1070
+ )
1071
+ chosen = choices[idx - 1]
1072
+ outcome = dispatch_entry(chosen, repo_entry, cfg, board, tracking_issue=tracking_issue)
1073
+ _echo_outcome(outcome)
1074
+ if not outcome.ok:
1075
+ sys.exit(1)
1076
+ return
1077
+
1078
+ # Bulk mode: dispatch the entire current ready frontier.
1079
+ click.echo("Will dispatch now:")
1080
+ if plan.to_dispatch:
1081
+ for pick in plan.to_dispatch:
1082
+ _echo_pick(pick)
1083
+ else:
1084
+ click.echo(" (none)")
1085
+
1086
+ if plan.skipped:
1087
+ click.echo()
1088
+ click.echo("Ready but no idle machine:")
1089
+ for s in plan.skipped:
1090
+ click.echo(f" #{s.entry.issue_number}: {s.reason}")
1091
+
1092
+ if plan.waiting:
1093
+ click.echo()
1094
+ click.echo("Waiting:")
1095
+ for b in plan.waiting:
1096
+ click.echo(f" #{b.issue_number}: {b.reason}")
1097
+
1098
+ if dry_run:
1099
+ click.echo()
1100
+ click.echo("(dry run — not dispatched)")
1101
+ return
1102
+
1103
+ if not plan.to_dispatch:
1104
+ click.echo()
1105
+ click.echo("Nothing to dispatch right now.")
1106
+ return
1107
+
1108
+ click.echo()
1109
+ failures = 0
1110
+ for pick in plan.to_dispatch:
1111
+ outcome = dispatch_entry(pick, repo_entry, cfg, board, tracking_issue=tracking_issue)
1112
+ _echo_outcome(outcome)
1113
+ if not outcome.ok:
1114
+ failures += 1
1115
+
1116
+ if not is_milestone_complete(ctx):
1117
+ from coord.state import register_milestone_drain
1118
+
1119
+ register_milestone_drain(repo_name=repo_entry.name, tracking_issue=tracking_issue)
1120
+ click.echo()
1121
+ click.echo(
1122
+ f"Milestone #{tracking_issue} registered for daemon auto-drain "
1123
+ "(requires `milestone.auto_dispatch: true` in coordinator.yml + a "
1124
+ "daemon restart to activate; `coord milestone dispatch` still "
1125
+ "works as a manual re-drain either way)."
1126
+ )
1127
+
1128
+ if failures:
1129
+ sys.exit(1)
1130
+
1131
+
1132
+ @milestone_group.command(
1133
+ "drive",
1134
+ help=(
1135
+ "Put a milestone under gate control (#1929, epic #1440): the daemon "
1136
+ "walks it through Gate A (contract) -> work -> Gate B (architecture) "
1137
+ "-> Gate C (acceptance) -> Gate D (ship), one step per tick, from a "
1138
+ "durable board-backed gate record. The `work` state IS the frontier "
1139
+ "drain, so a gate-driven milestone is NOT also drained by the "
1140
+ "independently-gated `milestone.auto_dispatch` path. Every gate edge "
1141
+ "other than the A->work and work->B ones is currently an explicit, "
1142
+ "logged HOLD (the individual edge behaviours are epic #1440's other "
1143
+ "children) — never a silent fall-through. Use --dry-run to print the "
1144
+ "full planned sequence without dispatching or writing anything."
1145
+ ),
1146
+ )
1147
+ @click.argument("repo")
1148
+ @click.argument("tracking_issue", type=int)
1149
+ @click.option(
1150
+ "--dry-run", is_flag=True,
1151
+ help=(
1152
+ "Print the full planned gate sequence + the work order's ready "
1153
+ "frontier and exit. Dispatches nothing and writes no gate record."
1154
+ ),
1155
+ )
1156
+ @_CONFIG_OPTION
1157
+ def milestone_drive_cmd(
1158
+ repo: str, tracking_issue: int, dry_run: bool, config_path: Path
1159
+ ) -> None:
1160
+ import dataclasses
1161
+ import time as _time
1162
+
1163
+ from coord import board_service, state
1164
+ from coord import milestone_gate as mg
1165
+
1166
+ cfg = _load_config(config_path)
1167
+ repo_entry = cfg.repo(repo)
1168
+ if repo_entry is None:
1169
+ click.echo(f"error: unknown repo {repo!r}", err=True)
1170
+ sys.exit(2)
1171
+
1172
+ try:
1173
+ ctx = fetch_milestone_context(repo_entry, tracking_issue)
1174
+ except MilestoneDispatchError as e:
1175
+ click.echo(f"error: {e}", err=True)
1176
+ sys.exit(1)
1177
+
1178
+ # Resume, don't restart: an existing record is the source of truth for
1179
+ # which gate this milestone is in. `drive` on an already-driven milestone
1180
+ # re-reports its position rather than rewinding the walk to Gate A.
1181
+ try:
1182
+ existing = state.get_milestone_gate(
1183
+ repo_name=repo_entry.name, tracking_issue=tracking_issue
1184
+ )
1185
+ except Exception as e: # noqa: BLE001 — see coord.client.fetch_milestone_gate:
1186
+ # a thin client that can't reach the daemon can't tell "never
1187
+ # driven" from "already driven, record unreadable right now" —
1188
+ # restarting at Gate A below would silently clobber real progress
1189
+ # once `save_milestone_gate` lands its wholesale upsert (#1930,
1190
+ # epic #1440's non-blocking follow-up: same root cause as the
1191
+ # dispatch guard above).
1192
+ click.echo(
1193
+ f"error: could not read #{tracking_issue}'s existing gate record "
1194
+ f"({e}) — refusing to drive rather than risk restarting an "
1195
+ "in-progress walk this client can't see. Retry once the daemon "
1196
+ "is reachable, or run this on the daemon host directly.",
1197
+ err=True,
1198
+ )
1199
+ sys.exit(1)
1200
+ record = mg.GateRecord.from_dict(existing) if existing else None
1201
+ if record is None:
1202
+ if existing is not None:
1203
+ # A record was found but couldn't be parsed (unknown schema, or
1204
+ # a `gate` value this build doesn't recognize — e.g. written by
1205
+ # a different-schema client via the permissive `/milestone-gate`
1206
+ # endpoint). Rewinding silently to Gate A here would re-run
1207
+ # gates this milestone may have already cleared — the exact
1208
+ # thing GateRecord exists to prevent (see its `from_dict`
1209
+ # docstring). Loudly say so instead of guessing, mirroring the
1210
+ # warning `_milestone_gate_tick` logs for the same situation.
1211
+ click.echo(
1212
+ f"warning: existing gate record for {repo_entry.name}"
1213
+ f"#{tracking_issue} could not be parsed (raw={existing!r}) — "
1214
+ "starting a fresh walk at Gate A. If this milestone had "
1215
+ "already cleared gates, that history is now lost; inspect "
1216
+ "or delete the malformed record out of band before "
1217
+ "re-driving if that matters.",
1218
+ err=True,
1219
+ )
1220
+ now = _time.time()
1221
+ record = mg.GateRecord(
1222
+ repo_name=repo_entry.name,
1223
+ tracking_issue=tracking_issue,
1224
+ gate=mg.GATE_A,
1225
+ entered_at=now,
1226
+ updated_at=now,
1227
+ milestone_number=ctx.milestone_number,
1228
+ )
1229
+ else:
1230
+ record = dataclasses.replace(record, milestone_number=ctx.milestone_number)
1231
+
1232
+ board = board_service.read_board()
1233
+ probes = mg.probe_milestone(ctx, board, cfg, repo_entry)
1234
+ steps = mg.plan_sequence(record, probes)
1235
+
1236
+ # The frontier half of the dry-run report. Computed for both modes so the
1237
+ # non-dry-run confirmation shows what the first `work` tick will do.
1238
+ if ctx.work_order.nodes:
1239
+ plan = plan_dispatch(
1240
+ ctx.work_order, board, cfg, repo_entry, ctx.terminal_issues
1241
+ )
1242
+ to_dispatch, skipped, waiting = plan.to_dispatch, plan.skipped, plan.waiting
1243
+ else:
1244
+ to_dispatch, skipped, waiting = [], [], []
1245
+
1246
+ if dry_run:
1247
+ for line in mg.format_plan(
1248
+ record, steps,
1249
+ to_dispatch=to_dispatch, skipped=skipped, waiting=waiting,
1250
+ ):
1251
+ click.echo(line)
1252
+ return
1253
+
1254
+ if not ctx.work_order.nodes:
1255
+ click.echo(
1256
+ f"error: #{tracking_issue} has no `## Work order` block — nothing "
1257
+ "to drive (write one with `coord milestone write-order`)",
1258
+ err=True,
1259
+ )
1260
+ sys.exit(1)
1261
+
1262
+ # #1930: this call is the ENTIRE side effect of `drive` — it persists the
1263
+ # record (unchanged, if one already existed) but never calls
1264
+ # `mg.apply_step` and never dispatches. Only the daemon's
1265
+ # `_milestone_gate_tick` advances the walk or drains `work`, so running
1266
+ # `drive` again (same operator, a second operator, a second machine) is
1267
+ # inert: same key, same record, nothing to race. See
1268
+ # `coord.milestone_gate`'s "Exactly one overseer" module docstring.
1269
+ state.save_milestone_gate(record.to_dict())
1270
+ for line in mg.format_plan(
1271
+ record, steps, to_dispatch=to_dispatch, skipped=skipped, waiting=waiting,
1272
+ footer=False,
1273
+ ):
1274
+ click.echo(line)
1275
+ click.echo()
1276
+ click.echo(
1277
+ f"Milestone #{tracking_issue} is now under gate control at "
1278
+ f"{mg.GATE_LABELS.get(record.gate, record.gate)}. The daemon advances "
1279
+ "it one gate per tick; `coord milestone drive --dry-run` re-prints the "
1280
+ "plan at any time."
1281
+ )
1282
+
1283
+
1284
+ def _resolve_milestone_membership(
1285
+ repo_entry, milestone_number: int, work_order: WorkOrder
1286
+ ) -> tuple[set[int], set[int]]:
1287
+ """Resolve which of *work_order*'s issue numbers belong to the milestone
1288
+ and which have already reached a closed/terminal state.
1289
+
1290
+ Open issues under the milestone come free from one ``get_open_issues``
1291
+ call; any work-order node not in that set gets an individual
1292
+ ``get_issue`` lookup (closed, or foreign) to classify it. Used by
1293
+ ``coord milestone write-order`` to validate proposed work-order content
1294
+ before writing it (``coord milestone order``/``dispatch`` resolve
1295
+ membership via ``milestone_dispatch.fetch_milestone_context`` instead).
1296
+ """
1297
+ from coord import github_ops
1298
+
1299
+ open_issues = github_ops.get_open_issues(repo_entry.github)
1300
+ milestone_issue_numbers = {
1301
+ i["number"]
1302
+ for i in open_issues
1303
+ if (i.get("milestone") or {}).get("number") == milestone_number
1304
+ }
1305
+ terminal_issues: set[int] = set()
1306
+ for node in work_order.nodes:
1307
+ if node.issue_number in milestone_issue_numbers:
1308
+ continue
1309
+ node_data = github_ops.get_issue(repo_entry.github, node.issue_number)
1310
+ node_milestone_number = (node_data.get("milestone") or {}).get("number")
1311
+ if node_milestone_number == milestone_number:
1312
+ milestone_issue_numbers.add(node.issue_number)
1313
+ if node_data.get("state", "").upper() == "CLOSED":
1314
+ terminal_issues.add(node.issue_number)
1315
+ return milestone_issue_numbers, terminal_issues
1316
+
1317
+
1318
+ @milestone_group.command(
1319
+ "write-order",
1320
+ help=(
1321
+ "#770 (Phase 2 of #767): validate and write a `## Work order` block "
1322
+ "into a milestone tracking issue.\n\n"
1323
+ "Reads the new checklist lines (e.g. `- #762 {group: A}`, no "
1324
+ "heading) from --file or stdin, splices them into the tracking "
1325
+ "issue's current body (replacing any existing `## Work order` "
1326
+ "section — idempotent, never duplicated), re-parses and validates "
1327
+ "the result (cycles, unknown `after` targets, milestone membership) "
1328
+ "BEFORE writing, then calls `github_ops.update_issue_body` — the "
1329
+ "`coord`, never-raw-`gh` write path #645/#770 require. Also ensures "
1330
+ "the tracking issue carries the `epic` label (#1057) — added if "
1331
+ "missing, even when the checklist body itself is unchanged — since "
1332
+ "`coord plans`/the TUI Plans panel find a milestone's tracking epic "
1333
+ "by that label, not by the presence of a `## Work order` block.\n\n"
1334
+ "REPO is the local repo name from coordinator.yml; TRACKING_ISSUE is "
1335
+ "the GH issue number of the tracking issue (must carry a milestone)."
1336
+ ),
1337
+ )
1338
+ @click.argument("repo")
1339
+ @click.argument("tracking_issue", type=int)
1340
+ @click.option(
1341
+ "--file",
1342
+ "block_file",
1343
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
1344
+ default=None,
1345
+ help="Path to a file with the new checklist lines. Reads stdin when omitted.",
1346
+ )
1347
+ @_CONFIG_OPTION
1348
+ def milestone_write_order_cmd(
1349
+ repo: str, tracking_issue: int, block_file: Path | None, config_path: Path
1350
+ ) -> None:
1351
+ cfg = _load_config(config_path)
1352
+ repo_entry = cfg.repo(repo)
1353
+ if repo_entry is None:
1354
+ click.echo(f"error: unknown repo {repo!r}", err=True)
1355
+ sys.exit(2)
1356
+
1357
+ new_block = (
1358
+ block_file.read_text() if block_file is not None else sys.stdin.read()
1359
+ )
1360
+ if not new_block.strip():
1361
+ click.echo("error: no work-order content given (empty --file/stdin)", err=True)
1362
+ sys.exit(2)
1363
+
1364
+ from coord import github_ops
1365
+
1366
+ try:
1367
+ issue_data = github_ops.get_issue(repo_entry.github, tracking_issue)
1368
+ except RuntimeError as e:
1369
+ click.echo(f"error: could not fetch #{tracking_issue}: {e}", err=True)
1370
+ sys.exit(1)
1371
+
1372
+ milestone = issue_data.get("milestone") or {}
1373
+ milestone_number = milestone.get("number")
1374
+ if milestone_number is None:
1375
+ click.echo(f"error: #{tracking_issue} has no milestone", err=True)
1376
+ sys.exit(1)
1377
+
1378
+ old_body = issue_data.get("body") or ""
1379
+ candidate_body = replace_work_order_section(old_body, new_block)
1380
+
1381
+ try:
1382
+ work_order = parse_work_order(candidate_body)
1383
+ except WorkOrderError as e:
1384
+ click.echo(f"error: proposed work order is invalid: {e}", err=True)
1385
+ sys.exit(1)
1386
+
1387
+ if not work_order.nodes:
1388
+ click.echo(
1389
+ "error: proposed work order is empty — refusing to write an "
1390
+ "empty `## Work order` block",
1391
+ err=True,
1392
+ )
1393
+ sys.exit(1)
1394
+
1395
+ try:
1396
+ milestone_issue_numbers, _terminal = _resolve_milestone_membership(
1397
+ repo_entry, milestone_number, work_order
1398
+ )
1399
+ except RuntimeError as e:
1400
+ click.echo(f"error: could not resolve milestone membership: {e}", err=True)
1401
+ sys.exit(1)
1402
+
1403
+ try:
1404
+ validate_milestone_membership(work_order, milestone_issue_numbers)
1405
+ except WorkOrderError as e:
1406
+ click.echo(f"error: {e}", err=True)
1407
+ sys.exit(1)
1408
+
1409
+ # #1057: a valid `## Work order` block alone doesn't make this issue
1410
+ # findable as the milestone's tracking epic — `coord plans`/the TUI
1411
+ # Plans panel key off the `epic` label (TRACKING_ISSUE_LABEL). Ensure it
1412
+ # here so promotion is self-consistent, and do it even when the
1413
+ # checklist body below turns out to be an idempotent no-op (the #1051
1414
+ # repro: a valid work order already present, label missing).
1415
+ current_labels = {lbl.get("name") for lbl in issue_data.get("labels") or []}
1416
+ label_added = False
1417
+ if TRACKING_ISSUE_LABEL not in current_labels:
1418
+ github_ops.add_issue_labels(
1419
+ repo_entry.github, tracking_issue, [TRACKING_ISSUE_LABEL]
1420
+ )
1421
+ label_added = True
1422
+ click.echo(
1423
+ f"#{tracking_issue}: added missing '{TRACKING_ISSUE_LABEL}' label "
1424
+ "(tracking issues must carry it to be found as an epic)"
1425
+ )
1426
+
1427
+ if candidate_body == old_body:
1428
+ click.echo(f"#{tracking_issue}: work order unchanged (idempotent no-op)")
1429
+ return
1430
+
1431
+ github_ops.update_issue_body(repo_entry.github, tracking_issue, candidate_body)
1432
+ click.echo(
1433
+ f"#{tracking_issue}: wrote `## Work order` block "
1434
+ f"({len(work_order.nodes)} node(s))"
1435
+ )
1436
+
1437
+
1438
+ @milestone_group.command(
1439
+ "chat",
1440
+ help=(
1441
+ "#770 (Phase 2 of #767) + #1009: dispatch a milestone-steward chat "
1442
+ "session.\n\n"
1443
+ "Seeds a `type=\"milestone-chat\"` `claude -p` worker with the "
1444
+ "milestone tracking issue's current body and the open issues filed "
1445
+ "under the milestone, then prints the new assignment id to stdout. "
1446
+ "The steward discusses the milestone, proposes a `## Work order` "
1447
+ "block inferring parallel cohorts (`group`) vs. hard dependencies "
1448
+ "(`after`) from the issue bodies, and can also propose creating/"
1449
+ "editing the milestone, assigning an issue to it, or splicing a "
1450
+ "sub-issue onto its epic — each written only once the operator "
1451
+ "confirms in the conversation, via `coord milestone write-order`/"
1452
+ "`create`/`edit`/`assign`/`add-child` (never raw `gh`).\n\n"
1453
+ "REPO is the local repo name from coordinator.yml; TRACKING_ISSUE is "
1454
+ "the GH issue number of the tracking issue (must carry a milestone). "
1455
+ "Pass `--new` instead of TRACKING_ISSUE to start a chat for a "
1456
+ "brand-new milestone that doesn't have a tracking issue yet. Pass "
1457
+ "`--add-child ISSUE` alongside TRACKING_ISSUE (#1017) to seed an "
1458
+ "\"add sub-issue\" chat about splicing ISSUE onto the epic's `## "
1459
+ "Sub-issues` checklist via `coord milestone add-child`."
1460
+ ),
1461
+ )
1462
+ @click.argument("repo")
1463
+ @click.argument("tracking_issue", type=int, required=False, default=None)
1464
+ @click.option(
1465
+ "--new",
1466
+ "is_new",
1467
+ is_flag=True,
1468
+ help="Start a chat to create a brand-new milestone (no tracking issue yet) instead of discussing an existing one.",
1469
+ )
1470
+ @click.option(
1471
+ "--title",
1472
+ "seed_title",
1473
+ default=None,
1474
+ help="Optional seed title for --new (the operator can still change it in conversation).",
1475
+ )
1476
+ @click.option(
1477
+ "--seed",
1478
+ "seed_prompt",
1479
+ default=None,
1480
+ help="Optional seed prompt for --new describing the milestone's goal/scope.",
1481
+ )
1482
+ @click.option(
1483
+ "--add-child",
1484
+ "add_child_issue",
1485
+ type=int,
1486
+ default=None,
1487
+ help="Seed an \"add sub-issue\" chat (#1017) about splicing this candidate issue onto TRACKING_ISSUE's epic via `coord milestone add-child`. Requires TRACKING_ISSUE; invalid with --new.",
1488
+ )
1489
+ @click.option(
1490
+ "--machine",
1491
+ default=None,
1492
+ help="Override machine selection (default: first unpaused machine that lists the repo).",
1493
+ )
1494
+ @_CONFIG_OPTION
1495
+ def milestone_chat_cmd(
1496
+ repo: str,
1497
+ tracking_issue: int | None,
1498
+ is_new: bool,
1499
+ seed_title: str | None,
1500
+ seed_prompt: str | None,
1501
+ add_child_issue: int | None,
1502
+ machine: str | None,
1503
+ config_path: Path,
1504
+ ) -> None:
1505
+ cfg = _load_config(config_path)
1506
+ repo_entry = cfg.repo(repo)
1507
+ if repo_entry is None:
1508
+ click.echo(f"error: unknown repo {repo!r}", err=True)
1509
+ sys.exit(2)
1510
+
1511
+ if is_new:
1512
+ if tracking_issue is not None:
1513
+ click.echo("error: pass either TRACKING_ISSUE or --new, not both", err=True)
1514
+ sys.exit(2)
1515
+ if add_child_issue is not None:
1516
+ click.echo("error: --add-child requires TRACKING_ISSUE, not --new", err=True)
1517
+ sys.exit(2)
1518
+
1519
+ from coord.milestone_chat import dispatch_new_milestone_chat
1520
+
1521
+ try:
1522
+ assignment_id, _picked_machine = dispatch_new_milestone_chat(
1523
+ repo,
1524
+ cfg,
1525
+ seed_title=seed_title,
1526
+ seed_prompt=seed_prompt,
1527
+ machine_override=machine,
1528
+ )
1529
+ except RuntimeError as exc:
1530
+ click.echo(f"error: {exc}", err=True)
1531
+ sys.exit(1)
1532
+ except httpx.HTTPError as exc:
1533
+ # #1017 review: dispatch_with_retry raises httpx.HTTPError (not
1534
+ # RuntimeError) after exhausting retries on a transient network
1535
+ # failure — previously this escaped as an unhandled traceback,
1536
+ # which looked to the TUI operator like a silent no-op (the
1537
+ # subprocess still exited non-zero, but no clean one-line
1538
+ # `error:` reason was there for `first_meaningful_stderr_line`
1539
+ # to surface as a toast).
1540
+ from coord.network import classify_error
1541
+
1542
+ state, reason = classify_error(exc)
1543
+ click.echo(f"error: dispatch failed — {state}: {reason}", err=True)
1544
+ sys.exit(1)
1545
+ except ValueError as exc:
1546
+ # dispatch() raises ValueError for an unknown machine or a
1547
+ # missing repo_path — likewise not a RuntimeError, likewise
1548
+ # deserves a clean one-line message instead of a traceback.
1549
+ click.echo(f"error: dispatch failed — {exc}", err=True)
1550
+ sys.exit(1)
1551
+ else:
1552
+ if tracking_issue is None:
1553
+ click.echo("error: TRACKING_ISSUE is required unless --new is given", err=True)
1554
+ sys.exit(2)
1555
+ if seed_title is not None or seed_prompt is not None:
1556
+ click.echo("error: --title/--seed only apply with --new", err=True)
1557
+ sys.exit(2)
1558
+
1559
+ from coord.milestone_chat import dispatch_milestone_chat
1560
+
1561
+ try:
1562
+ assignment_id, _picked_machine = dispatch_milestone_chat(
1563
+ repo,
1564
+ tracking_issue,
1565
+ cfg,
1566
+ machine_override=machine,
1567
+ add_child_issue=add_child_issue,
1568
+ )
1569
+ except RuntimeError as exc:
1570
+ click.echo(f"error: {exc}", err=True)
1571
+ sys.exit(1)
1572
+ except httpx.HTTPError as exc:
1573
+ # See the --new branch above (#1017 review) for why this and the
1574
+ # ValueError case are caught separately from RuntimeError.
1575
+ from coord.network import classify_error
1576
+
1577
+ state, reason = classify_error(exc)
1578
+ click.echo(f"error: dispatch failed — {state}: {reason}", err=True)
1579
+ sys.exit(1)
1580
+ except ValueError as exc:
1581
+ click.echo(f"error: dispatch failed — {exc}", err=True)
1582
+ sys.exit(1)
1583
+
1584
+ # Print the assignment id as the LAST stdout line so callers (the TUI,
1585
+ # eventually — #771/#645) can capture it with a "last non-empty line"
1586
+ # parse, matching refine-chat / new-issue-chat.
1587
+ click.echo(assignment_id)
1588
+
1589
+
1590
+ @milestone_group.command(
1591
+ "gate-c",
1592
+ help=(
1593
+ "Gate C (docs/ORACLE_LOOP.md, #932): the milestone's FULL "
1594
+ "accumulated acceptance suite must be green before it ships — "
1595
+ "catches the integration gaps *between* issues that per-issue "
1596
+ "acceptance runs miss. Runs the repo's acceptance driver once "
1597
+ "against the current checkout (`--path`, default: the repo's "
1598
+ "configured local checkout) and reports pass/fail with a "
1599
+ "non-zero exit on red, alongside a per-issue rollup of the "
1600
+ "milestone's own Acceptance box state (e.g. \"3/7 acceptance "
1601
+ "green\") for visibility. This is a manual, read-only check the "
1602
+ "operator runs before shipping — it mutates no git state and "
1603
+ "blocks nothing automatically. The ship step itself is `coord "
1604
+ "milestone ship` (Gate D, #934), which merges `feature/ms-NN → "
1605
+ "develop`."
1606
+ ),
1607
+ )
1608
+ @click.argument("repo")
1609
+ @click.argument("tracking_issue", type=int)
1610
+ @click.option(
1611
+ "--path", "path_opt", type=click.Path(file_okay=False), default=None,
1612
+ help="Repo checkout to run the driver in (default: repo_paths in coordinator.yml).",
1613
+ )
1614
+ @click.option(
1615
+ "--for-path", "route_path", default=None,
1616
+ help=(
1617
+ "Repo-relative path (e.g. 'coord/foo.py') used to resolve a "
1618
+ "routed acceptance driver (acceptance.drivers.<repo>.routes) — "
1619
+ "required when the repo's driver is routed; unused/ignored for a "
1620
+ "flat (unrouted) driver. NOT the same as --path (the checkout dir)."
1621
+ ),
1622
+ )
1623
+ @_CONFIG_OPTION
1624
+ def milestone_gate_c_cmd(
1625
+ repo: str,
1626
+ tracking_issue: int,
1627
+ path_opt: str | None,
1628
+ route_path: str | None,
1629
+ config_path: Path,
1630
+ ) -> None:
1631
+ from coord.acceptance import build_verdict, ms_dirname
1632
+ from coord.acceptance_drivers import DriverError, run_driver
1633
+
1634
+ cfg = _load_config(config_path)
1635
+ repo_entry = cfg.repo(repo)
1636
+ if repo_entry is None:
1637
+ click.echo(f"error: unknown repo {repo!r}", err=True)
1638
+ sys.exit(2)
1639
+
1640
+ driver_cfg = cfg.acceptance.driver_for(repo, route_path)
1641
+ if driver_cfg is None:
1642
+ if cfg.acceptance.has_driver(repo):
1643
+ click.echo(
1644
+ f"error: repo {repo!r} has a routed acceptance driver "
1645
+ "(acceptance.drivers routes) but no route matched — pass "
1646
+ "--for-path to select the subtree (e.g. 'coord/**')",
1647
+ err=True,
1648
+ )
1649
+ else:
1650
+ click.echo(
1651
+ f"error: no acceptance driver configured for repo {repo!r} "
1652
+ "(add it under acceptance.drivers in coordinator.yml)",
1653
+ err=True,
1654
+ )
1655
+ sys.exit(1)
1656
+
1657
+ if path_opt is not None:
1658
+ repo_dir = Path(path_opt).expanduser()
1659
+ else:
1660
+ from coord.test_orchestrator import find_local_repo_path
1661
+
1662
+ found = find_local_repo_path(repo, cfg)
1663
+ if found is None:
1664
+ click.echo(
1665
+ f"error: no local repo checkout found for {repo!r} "
1666
+ "(repo_paths in coordinator.yml) — pass --path",
1667
+ err=True,
1668
+ )
1669
+ sys.exit(1)
1670
+ repo_dir = found
1671
+
1672
+ try:
1673
+ ctx = fetch_milestone_context(repo_entry, tracking_issue)
1674
+ except MilestoneDispatchError as e:
1675
+ click.echo(f"error: {e}", err=True)
1676
+ sys.exit(1)
1677
+
1678
+ click.echo(
1679
+ f"Gate C for #{tracking_issue} (milestone #{ctx.milestone_number}) "
1680
+ f"— running the full accumulated acceptance suite in {repo_dir}..."
1681
+ )
1682
+ # #1125 review finding 2: gate-c already knows the milestone number
1683
+ # directly (no manifest lookup needed, unlike the per-issue `run`/
1684
+ # `record` commands) — resolve `{ms}` from it so a routed
1685
+ # `run: "pytest tests/acceptance/{ms}"` driver actually runs the right
1686
+ # suite dir for Gate C's "full accumulated suite" semantics.
1687
+ ms = ms_dirname(ctx.milestone_number)
1688
+ try:
1689
+ result = run_driver(driver_cfg.kind, driver_cfg.run, cwd=str(repo_dir), ms=ms)
1690
+ except DriverError as e:
1691
+ click.echo(f"error: {e}", err=True)
1692
+ sys.exit(1)
1693
+
1694
+ if not result.tests and result.exit_code != 0:
1695
+ click.echo(result.raw_output, err=True)
1696
+
1697
+ verdict = build_verdict(result.tests, scope="all")
1698
+ click.echo(json.dumps(verdict, indent=2))
1699
+
1700
+ # Per-issue rollup: how many of the milestone's own issues already have
1701
+ # a passed Acceptance box, for visibility alongside the full-suite
1702
+ # verdict (docs/ORACLE_LOOP.md's "3/7 acceptance green" example is a
1703
+ # per-milestone-member reading, not this full-suite one).
1704
+ if ctx.work_order.nodes:
1705
+ from coord import board_service
1706
+ from coord.diagnose import stage_assignments
1707
+
1708
+ board = board_service.read_board()
1709
+ passed = 0
1710
+ with_signal = 0
1711
+ for node in ctx.work_order.nodes:
1712
+ work = stage_assignments(board, repo, node.issue_number, "work")
1713
+ with_state = [a for a in work if (a.acceptance_state or "") != ""]
1714
+ if not with_state:
1715
+ continue
1716
+ with_signal += 1
1717
+ if with_state[0].acceptance_state == "passed":
1718
+ passed += 1
1719
+ click.echo(
1720
+ f"\nMilestone Acceptance boxes: {passed}/{with_signal} passed "
1721
+ f"({len(ctx.work_order.nodes)} issue(s) total, "
1722
+ f"{len(ctx.work_order.nodes) - with_signal} with no verdict yet)"
1723
+ )
1724
+
1725
+ if verdict["total"] == 0 or not verdict["green"]:
1726
+ click.echo(f"\nGate C RED for #{tracking_issue} — milestone is not ready to ship.")
1727
+ sys.exit(1)
1728
+ click.echo(f"\nGate C GREEN for #{tracking_issue}.")
1729
+
1730
+
1731
+ @milestone_group.command(
1732
+ "gate-b",
1733
+ help=(
1734
+ "Gate B (docs/PIPELINE_V2.md, #933): after every issue in the "
1735
+ "milestone has landed, dispatch an independent architecture review "
1736
+ "that checks the ASSEMBLED result against the Gate-A contract — "
1737
+ "was it implemented to spec, did the pieces integrate. Routes "
1738
+ "through the review pipeline (type=review, gh access via the "
1739
+ "coordinator posting on the reviewer's behalf) rather than "
1740
+ "`coord assign`, whose workers have `gh` denied. The verdict is "
1741
+ "posted as a comment on TRACKING_ISSUE when the reviewer finishes; "
1742
+ "request-changes means bounce, not ship — this command itself is a "
1743
+ "manual, non-automated gate, same posture as `coord milestone "
1744
+ "gate-c`. The ship step itself is `coord milestone ship` (Gate D, "
1745
+ "#934), which merges `feature/ms-NN -> develop`."
1746
+ ),
1747
+ )
1748
+ @click.argument("repo")
1749
+ @click.argument("tracking_issue", type=int)
1750
+ @click.option(
1751
+ "--machine", "machine_name", default=None,
1752
+ help="Dispatch to this machine by name instead of the first idle/capable one.",
1753
+ )
1754
+ @click.option(
1755
+ "--dry-run", is_flag=True,
1756
+ help="Show the target machine + issue list without dispatching.",
1757
+ )
1758
+ @_CONFIG_OPTION
1759
+ def milestone_gate_b_cmd(
1760
+ repo: str,
1761
+ tracking_issue: int,
1762
+ machine_name: str | None,
1763
+ dry_run: bool,
1764
+ config_path: Path,
1765
+ ) -> None:
1766
+ from coord import board_service
1767
+ from coord.gate_b import GateBError, dispatch_gate_b_review
1768
+
1769
+ cfg = _load_config(config_path)
1770
+ repo_entry = cfg.repo(repo)
1771
+ if repo_entry is None:
1772
+ click.echo(f"error: unknown repo {repo!r}", err=True)
1773
+ sys.exit(2)
1774
+
1775
+ try:
1776
+ ctx = fetch_milestone_context(repo_entry, tracking_issue)
1777
+ except MilestoneDispatchError as e:
1778
+ click.echo(f"error: {e}", err=True)
1779
+ sys.exit(1)
1780
+
1781
+ # Gate A must already be satisfied — Gate B's rubric IS the Gate-A
1782
+ # contract, so there is nothing to review against otherwise.
1783
+ block_reason = gate_a_status(repo_entry, cfg, ctx.milestone_number)
1784
+ if block_reason:
1785
+ click.echo(f"error: {block_reason}", err=True)
1786
+ sys.exit(1)
1787
+
1788
+ if not ctx.work_order.nodes:
1789
+ click.echo(
1790
+ f"#{tracking_issue}: no `## Work order` block found — nothing for "
1791
+ "Gate B to review",
1792
+ err=True,
1793
+ )
1794
+ sys.exit(1)
1795
+
1796
+ if not is_milestone_complete(ctx):
1797
+ incomplete = [
1798
+ n.issue_number
1799
+ for n in ctx.work_order.nodes
1800
+ if n.issue_number not in ctx.terminal_issues
1801
+ ]
1802
+ click.echo(
1803
+ "error: Gate B runs only after every issue in the milestone has "
1804
+ "landed — still open: "
1805
+ + ", ".join(f"#{n}" for n in incomplete),
1806
+ err=True,
1807
+ )
1808
+ sys.exit(1)
1809
+
1810
+ board = board_service.read_board()
1811
+
1812
+ if machine_name is not None:
1813
+ machine = next((m for m in cfg.machines if m.name == machine_name), None)
1814
+ if machine is None:
1815
+ click.echo(f"error: unknown machine {machine_name!r}", err=True)
1816
+ sys.exit(2)
1817
+ else:
1818
+ machine = pick_machine(repo, board, cfg)
1819
+ if machine is None:
1820
+ click.echo(
1821
+ f"error: no idle, capable, unpaused machine available for {repo!r}",
1822
+ err=True,
1823
+ )
1824
+ sys.exit(1)
1825
+
1826
+ click.echo(
1827
+ f"Gate B for #{tracking_issue} (milestone #{ctx.milestone_number}), "
1828
+ f"{len(ctx.work_order.nodes)} issue(s) -> {machine.name}"
1829
+ )
1830
+
1831
+ if dry_run:
1832
+ click.echo("(dry run — not dispatched)")
1833
+ return
1834
+
1835
+ try:
1836
+ assignment = dispatch_gate_b_review(
1837
+ repo_cfg=repo_entry,
1838
+ config=cfg,
1839
+ machine=machine,
1840
+ tracking_issue=tracking_issue,
1841
+ milestone_number=ctx.milestone_number,
1842
+ work_order=ctx.work_order,
1843
+ board=board,
1844
+ )
1845
+ except GateBError as e:
1846
+ click.echo(f"error: {e}", err=True)
1847
+ sys.exit(1)
1848
+
1849
+ click.echo(f"dispatched: assignment {assignment.assignment_id} on {machine.name}")
1850
+
1851
+
1852
+ @milestone_group.command(
1853
+ "ship",
1854
+ help=(
1855
+ "Gate D (docs/PIPELINE_V2.md, #934): ship a milestone's "
1856
+ "`feature/ms-NN` branch to `develop` — the last step of the "
1857
+ "develop + feature-branch-per-milestone git model. Refuses unless "
1858
+ "the repo has opted in (`develop_branch` set in coordinator.yml) "
1859
+ "AND both Gate B (an `approve` verdict — `coord milestone gate-b`) "
1860
+ "AND Gate C (the full acceptance suite, re-run live here, the same "
1861
+ "check `coord milestone gate-c` performs) are green. On success, "
1862
+ "opens (or reuses) and merges a PR from `feature/ms-NN` into "
1863
+ "`develop_branch`."
1864
+ ),
1865
+ )
1866
+ @click.argument("repo")
1867
+ @click.argument("tracking_issue", type=int)
1868
+ @click.option(
1869
+ "--path", "path_opt", type=click.Path(file_okay=False), default=None,
1870
+ help="Repo checkout to run Gate C's driver in (default: repo_paths in coordinator.yml).",
1871
+ )
1872
+ @click.option(
1873
+ "--for-path", "route_path", default=None,
1874
+ help="Repo-relative path used to resolve a routed acceptance driver (mirrors `gate-c`).",
1875
+ )
1876
+ @click.option(
1877
+ "--method", "merge_method", default="merge",
1878
+ type=click.Choice(["merge", "squash", "rebase"]),
1879
+ help=(
1880
+ "PR merge method for feature/ms-NN -> develop (default: 'merge' — "
1881
+ "a real merge commit, so the milestone's own commit history is "
1882
+ "preserved as a unit rather than flattened/replayed onto develop)."
1883
+ ),
1884
+ )
1885
+ @click.option(
1886
+ "--dry-run", is_flag=True,
1887
+ help="Check both gates and report the ship plan without opening/merging a PR.",
1888
+ )
1889
+ @_CONFIG_OPTION
1890
+ def milestone_ship_cmd(
1891
+ repo: str,
1892
+ tracking_issue: int,
1893
+ path_opt: str | None,
1894
+ route_path: str | None,
1895
+ merge_method: str,
1896
+ dry_run: bool,
1897
+ config_path: Path,
1898
+ ) -> None:
1899
+ from coord import board_service, github_ops
1900
+ from coord.acceptance import build_verdict, ms_dirname
1901
+ from coord.acceptance_drivers import DriverError, run_driver
1902
+ from coord.branch_model import feature_branch_name
1903
+ from coord.gate_b import latest_gate_b_verdict
1904
+
1905
+ cfg = _load_config(config_path)
1906
+ repo_entry = cfg.repo(repo)
1907
+ if repo_entry is None:
1908
+ click.echo(f"error: unknown repo {repo!r}", err=True)
1909
+ sys.exit(2)
1910
+
1911
+ if not repo_entry.develop_branch:
1912
+ click.echo(
1913
+ f"error: repo {repo!r} has not opted into the develop + "
1914
+ "feature-branch-per-milestone git model (#934) — set "
1915
+ "develop_branch in coordinator.yml to ship",
1916
+ err=True,
1917
+ )
1918
+ sys.exit(1)
1919
+
1920
+ try:
1921
+ ctx = fetch_milestone_context(repo_entry, tracking_issue)
1922
+ except MilestoneDispatchError as e:
1923
+ click.echo(f"error: {e}", err=True)
1924
+ sys.exit(1)
1925
+
1926
+ if not is_milestone_complete(ctx):
1927
+ incomplete = [
1928
+ n.issue_number
1929
+ for n in ctx.work_order.nodes
1930
+ if n.issue_number not in ctx.terminal_issues
1931
+ ]
1932
+ click.echo(
1933
+ "error: cannot ship — still open: "
1934
+ + ", ".join(f"#{n}" for n in incomplete),
1935
+ err=True,
1936
+ )
1937
+ sys.exit(1)
1938
+
1939
+ feature_branch = feature_branch_name(ctx.milestone_number)
1940
+
1941
+ # ── Gate B ────────────────────────────────────────────────────────────
1942
+ board = board_service.read_board()
1943
+ gate_b_verdict = latest_gate_b_verdict(
1944
+ board, repo_entry.name, tracking_issue, ctx.milestone_number
1945
+ )
1946
+ if gate_b_verdict != "approve":
1947
+ seen = gate_b_verdict or "no Gate B review found"
1948
+ click.echo(
1949
+ f"error: Gate B is not green ({seen}) — run `coord milestone "
1950
+ f"gate-b {repo} {tracking_issue}` first",
1951
+ err=True,
1952
+ )
1953
+ sys.exit(1)
1954
+ click.echo(f"Gate B: approve (milestone #{ctx.milestone_number})")
1955
+
1956
+ # ── Gate C — re-run live, same as `coord milestone gate-c` ─────────────
1957
+ driver_cfg = cfg.acceptance.driver_for(repo, route_path)
1958
+ if driver_cfg is None:
1959
+ if cfg.acceptance.has_driver(repo):
1960
+ click.echo(
1961
+ f"error: repo {repo!r} has a routed acceptance driver "
1962
+ "(acceptance.drivers routes) but no route matched — pass "
1963
+ "--for-path to select the subtree (e.g. 'coord/**')",
1964
+ err=True,
1965
+ )
1966
+ else:
1967
+ click.echo(
1968
+ f"error: no acceptance driver configured for repo {repo!r} "
1969
+ "(add it under acceptance.drivers in coordinator.yml)",
1970
+ err=True,
1971
+ )
1972
+ sys.exit(1)
1973
+
1974
+ if path_opt is not None:
1975
+ repo_dir = Path(path_opt).expanduser()
1976
+ else:
1977
+ from coord.test_orchestrator import find_local_repo_path
1978
+
1979
+ found = find_local_repo_path(repo, cfg)
1980
+ if found is None:
1981
+ click.echo(
1982
+ f"error: no local repo checkout found for {repo!r} "
1983
+ "(repo_paths in coordinator.yml) — pass --path",
1984
+ err=True,
1985
+ )
1986
+ sys.exit(1)
1987
+ repo_dir = found
1988
+
1989
+ click.echo(f"Gate C: running the full accumulated acceptance suite in {repo_dir}...")
1990
+ ms = ms_dirname(ctx.milestone_number)
1991
+ try:
1992
+ result = run_driver(driver_cfg.kind, driver_cfg.run, cwd=str(repo_dir), ms=ms)
1993
+ except DriverError as e:
1994
+ click.echo(f"error: {e}", err=True)
1995
+ sys.exit(1)
1996
+
1997
+ if not result.tests and result.exit_code != 0:
1998
+ click.echo(result.raw_output, err=True)
1999
+
2000
+ verdict = build_verdict(result.tests, scope="all")
2001
+ if verdict["total"] == 0 or not verdict["green"]:
2002
+ click.echo(
2003
+ f"error: Gate C is RED for #{tracking_issue} — milestone is not ready to ship",
2004
+ err=True,
2005
+ )
2006
+ sys.exit(1)
2007
+ click.echo(f"Gate C: GREEN ({verdict['passed']}/{verdict['total']})")
2008
+
2009
+ # ── Ship: feature/ms-NN -> develop ──────────────────────────────────────
2010
+ if dry_run:
2011
+ click.echo(
2012
+ f"(dry run) would open/merge a PR: {feature_branch} -> "
2013
+ f"{repo_entry.develop_branch} (method={merge_method}), then close "
2014
+ f"tracking issue #{tracking_issue} on success"
2015
+ )
2016
+ return
2017
+
2018
+ if not github_ops.branch_exists_on_remote(repo_entry.github, feature_branch):
2019
+ click.echo(
2020
+ f"error: {feature_branch} does not exist on {repo_entry.github} — "
2021
+ "nothing to ship (no issue in this milestone ever dispatched?)",
2022
+ err=True,
2023
+ )
2024
+ sys.exit(1)
2025
+
2026
+ pr = github_ops.create_pr(
2027
+ repo_entry.github,
2028
+ base=repo_entry.develop_branch,
2029
+ head=feature_branch,
2030
+ title=f"Ship milestone #{ctx.milestone_number}: {feature_branch} -> {repo_entry.develop_branch}",
2031
+ body=(
2032
+ f"Gate B + Gate C both green for milestone #{ctx.milestone_number} "
2033
+ f"(tracking issue #{tracking_issue}). Opened by `coord milestone ship`."
2034
+ ),
2035
+ )
2036
+ ok, message = github_ops.merge_pr(repo_entry.github, pr["number"], method=merge_method)
2037
+ if not ok:
2038
+ click.echo(
2039
+ f"error: PR #{pr['number']} ({pr['url']}) opened but merge failed: {message}",
2040
+ err=True,
2041
+ )
2042
+ sys.exit(1)
2043
+
2044
+ click.echo(
2045
+ f"shipped: {feature_branch} -> {repo_entry.develop_branch} "
2046
+ f"(PR #{pr['number']}, {pr['url']})"
2047
+ )
2048
+
2049
+ # ── Close the tracking issue ─────────────────────────────────────────
2050
+ # This is the completion signal Gate D (`coord milestone drive`,
2051
+ # `coord.milestone_gate.probe_milestone`'s `shipped` probe) watches for
2052
+ # — nothing else in the pipeline closes it. `force=True`: this command
2053
+ # already proved every work-order node terminal (`is_milestone_complete`
2054
+ # above) by the milestone's own definition of "done", which is not
2055
+ # necessarily the same set of issues GitHub's sub-issues API would list
2056
+ # as this issue's children, so the #1196 open-children guard would be
2057
+ # the wrong gate to also apply here. A close failure must not undo the
2058
+ # merge that already happened — report it and tell the operator how to
2059
+ # finish by hand rather than exiting non-zero.
2060
+ try:
2061
+ github_ops.close_issue(
2062
+ repo_entry.github,
2063
+ tracking_issue,
2064
+ comment=(
2065
+ f"Closed by `coord milestone ship`: {feature_branch} merged "
2066
+ f"into {repo_entry.develop_branch} (PR #{pr['number']})."
2067
+ ),
2068
+ force=True,
2069
+ )
2070
+ click.echo(
2071
+ f"closed tracking issue #{tracking_issue} "
2072
+ "(coord milestone drive's Gate D will observe this on its next tick)"
2073
+ )
2074
+ except RuntimeError as e:
2075
+ click.echo(
2076
+ f"warning: shipped but could not close tracking issue "
2077
+ f"#{tracking_issue}: {e} — close it manually (`gh issue close "
2078
+ f"{tracking_issue} --repo {repo_entry.github}`), otherwise Gate D "
2079
+ "will hold forever waiting for it",
2080
+ err=True,
2081
+ )