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,600 @@
1
+ """#770 (Phase 2 of #767) + #1009 + #1017: dispatch a `type="milestone-chat"`
2
+ session.
3
+
4
+ Lets the operator chat about a milestone's issues and — once they confirm —
5
+ write the `## Work order` block that ``coord/milestone_order.py`` (#768)
6
+ parses into a DAG/frontier. #1009 widened this beyond that original narrow
7
+ slice: the same propose-then-confirm-then-write discipline now also covers
8
+ creating a brand-new milestone (:func:`dispatch_new_milestone_chat`, no
9
+ tracking issue yet), editing an existing milestone's title/description/due
10
+ date, and assigning an issue to a milestone. #1017 adds a third seed mode —
11
+ an "add sub-issue" chat seeded with the epic body plus a candidate child
12
+ issue (:func:`build_milestone_chat_briefing`'s ``candidate_child_issue``
13
+ param) — now that #1008 shipped `coord milestone add-child`. See
14
+ ``MILESTONE_CHAT_SYSTEM_PROMPT`` in ``coord/agent.py`` for the exact
15
+ commands the worker is allowed to run. Full #645 "milestone-steward" scope
16
+ (creating/editing the *issues themselves*) stays out of scope until that
17
+ lands; nothing here forecloses it.
18
+
19
+ Used by `coord milestone chat <repo> <tracking_issue>` (existing milestone,
20
+ optionally `--add-child <issue>` to seed the add-sub-issue mode) and
21
+ `coord milestone chat <repo> --new` (brand-new milestone) — both CLI entry
22
+ points live in ``coord/commands/milestone.py``.
23
+
24
+ The session itself runs as a `type="milestone-chat"` assignment on an agent
25
+ server. Tools are `Read,Bash` (see `coord/agent.py`); a deny list blocks raw
26
+ `gh` mutations and unrelated `coord` write commands — the write actions the
27
+ worker may take are `coord milestone write-order`, `create`, `edit`,
28
+ `assign`, and `add-child`, and only after the operator has explicitly
29
+ confirmed the specific proposed change in conversation.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import dataclasses
34
+ import time
35
+ import uuid
36
+
37
+ from coord import github_ops
38
+ from coord.config import Config
39
+ from coord.models import Machine, Proposal
40
+ from coord.refine_chat import pick_refinement_machine
41
+
42
+ # Soft caps so the seed briefing stays bounded on milestones with many/large
43
+ # issues — mirrors the caps `new_issue_chat.py` / `refine_chat.py` use.
44
+ MAX_ISSUES = 50
45
+ MAX_ISSUE_BODY_CHARS = 1500
46
+
47
+
48
+ def pick_milestone_chat_machine(cfg: Config, repo: str) -> Machine | None:
49
+ """Pick a machine to run the milestone-chat session on.
50
+
51
+ Milestone-chat is read-only w.r.t. git (no worktree, no branch — its one
52
+ write action goes through `coord milestone write-order` against the
53
+ GitHub API, not the local checkout) and short-lived, so any qualified,
54
+ unpaused machine works — same selection `refine_chat`/`new_issue_chat`
55
+ use. Reused directly rather than re-implemented (identical logic).
56
+ """
57
+ return pick_refinement_machine(cfg, repo)
58
+
59
+
60
+ def _fetch_milestone_issues(slug: str, milestone_number: int) -> list[dict]:
61
+ """Best-effort fetch of open issues under *milestone_number*.
62
+
63
+ Returns a list of ``{"number", "title", "body"}`` dicts (bodies
64
+ truncated to ``MAX_ISSUE_BODY_CHARS`` so cohort/dependency inference has
65
+ signal without blowing the prompt budget), capped at ``MAX_ISSUES``.
66
+ Returns an empty list on any failure so the seed briefing still works
67
+ without issue context — the operator can still chat, just with less to
68
+ go on.
69
+ """
70
+ try:
71
+ issues = github_ops.get_open_issues(slug)
72
+ except RuntimeError:
73
+ return []
74
+ under_milestone = [
75
+ i for i in issues
76
+ if (i.get("milestone") or {}).get("number") == milestone_number
77
+ ]
78
+ out: list[dict] = []
79
+ for issue in under_milestone[:MAX_ISSUES]:
80
+ body = (issue.get("body") or "").strip()
81
+ if len(body) > MAX_ISSUE_BODY_CHARS:
82
+ body = body[:MAX_ISSUE_BODY_CHARS] + "\n...(truncated)"
83
+ out.append({
84
+ "number": issue.get("number"),
85
+ "title": issue.get("title") or "",
86
+ "body": body,
87
+ })
88
+ return out
89
+
90
+
91
+ def build_milestone_chat_briefing(
92
+ *,
93
+ repo_name: str,
94
+ repo_slug: str,
95
+ milestone_title: str,
96
+ tracking_issue_number: int,
97
+ tracking_issue_body: str,
98
+ issues: list[dict],
99
+ milestone_number: int | None = None,
100
+ milestone_description: str | None = None,
101
+ milestone_due_on: str | None = None,
102
+ candidate_child_issue: dict | None = None,
103
+ ) -> str:
104
+ """Compose the seed briefing the worker sees as its first user message.
105
+
106
+ The agent's ``MILESTONE_CHAT_SYSTEM_PROMPT`` already tells it how to
107
+ behave; this packs the milestone context into the first user turn.
108
+
109
+ *milestone_number*/*milestone_description*/*milestone_due_on* are
110
+ optional (#1009) — best-effort metadata so an "edit this milestone"
111
+ conversation has the current values to propose changes against without
112
+ a separate lookup. Omitted (``None``) when the caller couldn't fetch
113
+ them; the chat still works, just without that convenience context.
114
+
115
+ *candidate_child_issue* (#1017) is an optional ``{"number", "title",
116
+ "body"}`` dict — when supplied (the TUI's "Add sub-issue via chat…"
117
+ entry, or `coord milestone chat --add-child`), the briefing calls out
118
+ that candidate and steers the conversation toward proposing a `coord
119
+ milestone add-child` splice rather than a generic discussion.
120
+ """
121
+ parts: list[str] = []
122
+ parts.append(
123
+ f"=== Milestone chat context for {repo_slug} "
124
+ f"— milestone {milestone_title!r} ===\n"
125
+ )
126
+
127
+ if milestone_number is not None:
128
+ parts.append(f"MILESTONE NUMBER: {milestone_number}")
129
+ parts.append(
130
+ "CURRENT DESCRIPTION: "
131
+ + (milestone_description.strip() if milestone_description and milestone_description.strip() else "(none)")
132
+ )
133
+ parts.append(f"CURRENT DUE DATE: {milestone_due_on or '(none)'}")
134
+ parts.append("")
135
+
136
+ parts.append(f"TRACKING ISSUE: #{tracking_issue_number}")
137
+ parts.append(
138
+ "CURRENT TRACKING ISSUE BODY (may already carry a `## Work order` "
139
+ "block from a prior run — treat re-runs as idempotent updates, not "
140
+ "duplicates):"
141
+ )
142
+ parts.append(tracking_issue_body.strip() if tracking_issue_body.strip() else "(empty)")
143
+ parts.append("")
144
+
145
+ parts.append(f"OPEN ISSUES UNDER THIS MILESTONE ({len(issues)}):")
146
+ if issues:
147
+ for issue in issues:
148
+ parts.append(f"--- #{issue['number']}: {issue['title']} ---")
149
+ parts.append(issue["body"] if issue["body"] else "(no body)")
150
+ parts.append("")
151
+ else:
152
+ parts.append(" (none fetched)")
153
+
154
+ parts.append("---")
155
+ parts.append(
156
+ "Discuss this milestone with the operator. When asked to propose a "
157
+ "work order — or when it's clearly useful — infer parallel cohorts "
158
+ "(`group: <label>`) vs. hard dependencies (`after: #N[,#M...]`) from "
159
+ "the issue bodies above, present the proposed `## Work order` block, "
160
+ "and only after the operator explicitly confirms it, write it with:\n\n"
161
+ f" coord milestone write-order {repo_name} {tracking_issue_number} "
162
+ "<<'EOF'\n"
163
+ " - #762 {group: A}\n"
164
+ " ...\n"
165
+ " EOF\n\n"
166
+ "(#1061: no `[ ]`/`[x]` checkbox — the grammar dropped it since it "
167
+ "was never read for readiness; the parser still accepts the old "
168
+ "checkbox form on bodies that already have one, but propose new "
169
+ "lines checkbox-free.)"
170
+ )
171
+ if milestone_number is not None:
172
+ parts.append(
173
+ "The operator may also ask you to edit this milestone's title/"
174
+ "description/due date, or assign an issue to it — propose the "
175
+ "exact change, and once confirmed run:\n\n"
176
+ f" coord milestone edit {repo_name} {milestone_number} "
177
+ "[--title '...'] [--description '...'] [--due-on <iso8601>]\n"
178
+ f" coord milestone assign {repo_name} <issue> {milestone_number}\n\n"
179
+ "Always single-quote title/description values (never double-"
180
+ "quote them) — single quotes stop the shell from expanding "
181
+ "`$(...)`, backticks, or `$VAR` in operator-supplied text before "
182
+ "`coord` sees it. If a value itself contains a single quote, "
183
+ "escape it as `'\\''` (close the quote, insert an escaped quote, "
184
+ "reopen), e.g. \"operator's plan\" becomes 'operator'\\''s plan'."
185
+ )
186
+ if candidate_child_issue is not None:
187
+ child_number = candidate_child_issue.get("number")
188
+ child_title = candidate_child_issue.get("title") or ""
189
+ child_body = candidate_child_issue.get("body") or "(no body)"
190
+ parts.append("")
191
+ parts.append(
192
+ f"ADD SUB-ISSUE MODE: the operator wants to discuss splicing "
193
+ f"#{child_number} onto this epic's `## Sub-issues` checklist "
194
+ "(#1008/#1017) as a child of the tracking issue above. Candidate "
195
+ "issue:"
196
+ )
197
+ parts.append(f"--- #{child_number}: {child_title} ---")
198
+ parts.append(child_body)
199
+ parts.append("")
200
+ parts.append(
201
+ "Discuss whether this candidate belongs under the epic, and if "
202
+ "so, whether it should carry a `{group: <label>}` cohort or a "
203
+ "`{after: #N[,#M...]}` hard dependency — ground any inference in "
204
+ "explicit signals in the issue bodies, the same way you would "
205
+ "for a `## Work order` entry. Present the exact splice you're "
206
+ "proposing, and only after the operator explicitly confirms it, "
207
+ "run:\n\n"
208
+ f" coord milestone add-child {repo_name} {tracking_issue_number} "
209
+ f"{child_number} [--group '<group>'] [--after <N>[,<M>...]]\n\n"
210
+ "Omit --group/--after entirely for a bare splice with no "
211
+ "annotation. To remove a sub-issue instead, run `coord milestone "
212
+ f"add-child {repo_name} {tracking_issue_number} {child_number} "
213
+ "--remove` (only after the operator confirms removal)."
214
+ )
215
+ return "\n".join(parts)
216
+
217
+
218
+ def build_new_milestone_chat_briefing(
219
+ *,
220
+ repo_name: str,
221
+ repo_slug: str,
222
+ seed_title: str | None,
223
+ seed_prompt: str | None,
224
+ ) -> str:
225
+ """Compose the seed briefing for a brand-new milestone (#1009) — no
226
+ tracking issue exists yet, so there's nothing to fetch from GitHub.
227
+
228
+ *seed_title*/*seed_prompt* are whatever the operator supplied when
229
+ starting the chat (e.g. from a TUI "New milestone… → Chat about this"
230
+ action); both are optional since the operator may want to start from a
231
+ blank conversation.
232
+ """
233
+ parts: list[str] = []
234
+ parts.append(f"=== New-milestone chat context for {repo_slug} ===\n")
235
+ parts.append("No milestone exists yet for this conversation.")
236
+ parts.append(f"SEED TITLE: {seed_title if seed_title else '(none supplied)'}")
237
+ parts.append(
238
+ "SEED PROMPT: "
239
+ + (seed_prompt.strip() if seed_prompt and seed_prompt.strip() else "(none supplied)")
240
+ )
241
+ parts.append("")
242
+ parts.append("---")
243
+ parts.append(
244
+ "Discuss the new milestone's goal and scope with the operator — what "
245
+ "it's for, roughly what it covers, any target due date. Once you "
246
+ "both agree on a title (and optionally a description/due date), "
247
+ "present the exact values you'll use, and only after the operator "
248
+ "explicitly confirms, create it with:\n\n"
249
+ f" coord milestone create {repo_name} --title '<title>' "
250
+ "[--description '<desc>'] [--due-on <iso8601>]\n\n"
251
+ "Single-quote the title/description values (never double-quote "
252
+ "them) — single quotes stop the shell from expanding `$(...)`, "
253
+ "backticks, or `$VAR` before `coord` sees the text. If a value "
254
+ "contains a single quote, escape it as `'\\''` (close the quote, "
255
+ "insert an escaped quote, reopen), e.g. \"operator's plan\" becomes "
256
+ "'operator'\\''s plan'.\n\n"
257
+ "Report the printed milestone number back to the operator. There is "
258
+ "no tracking issue yet and none is created here — filing one (and "
259
+ "assigning it to the new milestone with `coord milestone assign`) is "
260
+ "a separate, later step."
261
+ )
262
+ return "\n".join(parts)
263
+
264
+
265
+ @dataclasses.dataclass
266
+ class MilestoneChatBriefing:
267
+ """Resolved milestone-chat context (#1029) — shared by the headless
268
+ (:func:`dispatch_milestone_chat`) and interactive
269
+ (``coord/commands/dispatch_workers.py::_dispatch_milestone_chat_of``)
270
+ dispatch paths so their seed prompts can never drift apart.
271
+ """
272
+
273
+ repo_github: str
274
+ tracking_title: str
275
+ milestone_title: str
276
+ milestone_number: int | None
277
+ briefing: str
278
+
279
+
280
+ def resolve_milestone_chat_briefing(
281
+ repo_name: str,
282
+ tracking_issue_number: int,
283
+ config: Config,
284
+ *,
285
+ add_child_issue: int | None = None,
286
+ ) -> MilestoneChatBriefing:
287
+ """Resolve the milestone/tracking-issue context and build the seed
288
+ briefing for a milestone-chat session (#1029 extraction — this used to
289
+ be the top half of :func:`dispatch_milestone_chat`, inlined).
290
+
291
+ *add_child_issue* (#1017) is an optional candidate child issue number —
292
+ when given, it's fetched and passed to
293
+ :func:`build_milestone_chat_briefing` as ``candidate_child_issue`` so
294
+ the seed steers the conversation toward an "Add sub-issue" chat (the
295
+ TUI's "Add sub-issue via chat…" entry, or `coord milestone chat
296
+ --add-child`). Best-effort: a fetch failure falls back to a plain
297
+ milestone chat rather than failing the whole dispatch.
298
+
299
+ Raises ``RuntimeError`` when the repo is unknown, the tracking issue
300
+ can't be fetched, or it has no milestone (and no ``add_child_issue`` to
301
+ fall back on).
302
+ """
303
+ repo_cfg = config.repo(repo_name)
304
+ if repo_cfg is None:
305
+ raise RuntimeError(f"repo {repo_name!r} not in coordinator.yml")
306
+
307
+ try:
308
+ issue_data = github_ops.get_issue(repo_cfg.github, tracking_issue_number)
309
+ except RuntimeError as e:
310
+ raise RuntimeError(f"could not fetch #{tracking_issue_number}: {e}") from e
311
+
312
+ milestone = issue_data.get("milestone") or {}
313
+ milestone_number = milestone.get("number")
314
+ if milestone_number is None:
315
+ # #1017: the "add sub-issue" chat targets an epic tracking issue's
316
+ # `## Sub-issues` checklist via `coord milestone add-child`, which
317
+ # operates purely on the issue *body* and does NOT require the epic
318
+ # to carry a GitHub milestone. Requiring one here was the root cause
319
+ # of the smoke-test "silent no-op": `coord milestone chat <repo>
320
+ # <epic> --add-child <issue>` bailed with `#<epic> has no milestone`
321
+ # (exit 1) for any epic without a GitHub milestone — the common case.
322
+ # Fall back to the epic's own title as the chat label and skip the
323
+ # milestone-scoped GitHub fetches below; `build_milestone_chat_briefing`
324
+ # already tolerates `milestone_number=None`. A plain milestone chat
325
+ # (no --add-child) still legitimately requires a milestone.
326
+ if add_child_issue is None:
327
+ raise RuntimeError(f"#{tracking_issue_number} has no milestone")
328
+ milestone_title = issue_data.get("title") or f"#{tracking_issue_number}"
329
+ else:
330
+ milestone_title = milestone.get("title") or f"#{milestone_number}"
331
+
332
+ # A milestone-less epic (add-child path, see above) has no milestone to
333
+ # scope issues to and no milestone metadata to fetch — skip both.
334
+ issues = (
335
+ _fetch_milestone_issues(repo_cfg.github, milestone_number)
336
+ if milestone_number is not None
337
+ else []
338
+ )
339
+
340
+ # Best-effort: pull the milestone's own description/due date so an
341
+ # "edit this milestone" conversation has current values to propose
342
+ # changes against (#1009). Never fatal — the chat still works without it.
343
+ milestone_description: str | None = None
344
+ milestone_due_on: str | None = None
345
+ if milestone_number is not None:
346
+ try:
347
+ ms_data = github_ops.get_milestone(repo_cfg.github, milestone_number)
348
+ milestone_description = ms_data.get("description")
349
+ milestone_due_on = ms_data.get("due_on")
350
+ except RuntimeError:
351
+ pass
352
+
353
+ # #1017: best-effort fetch of the candidate child issue for an "Add
354
+ # sub-issue" chat. A fetch failure just falls back to a plain milestone
355
+ # chat rather than failing the whole dispatch.
356
+ candidate_child_issue: dict | None = None
357
+ if add_child_issue is not None:
358
+ try:
359
+ child_data = github_ops.get_issue(repo_cfg.github, add_child_issue)
360
+ except RuntimeError:
361
+ child_data = None
362
+ if child_data is not None:
363
+ candidate_child_issue = {
364
+ "number": add_child_issue,
365
+ "title": child_data.get("title") or "",
366
+ "body": child_data.get("body") or "",
367
+ }
368
+
369
+ briefing = build_milestone_chat_briefing(
370
+ repo_name=repo_name,
371
+ repo_slug=repo_cfg.github,
372
+ milestone_title=milestone_title,
373
+ tracking_issue_number=tracking_issue_number,
374
+ tracking_issue_body=issue_data.get("body") or "",
375
+ issues=issues,
376
+ milestone_number=milestone_number,
377
+ milestone_description=milestone_description,
378
+ milestone_due_on=milestone_due_on,
379
+ candidate_child_issue=candidate_child_issue,
380
+ )
381
+
382
+ tracking_title = issue_data.get("title") or f"Milestone chat #{tracking_issue_number}"
383
+
384
+ return MilestoneChatBriefing(
385
+ repo_github=repo_cfg.github,
386
+ tracking_title=tracking_title,
387
+ milestone_title=milestone_title,
388
+ milestone_number=milestone_number,
389
+ briefing=briefing,
390
+ )
391
+
392
+
393
+ def dispatch_milestone_chat(
394
+ repo_name: str,
395
+ tracking_issue_number: int,
396
+ config: Config,
397
+ *,
398
+ machine_override: str | None = None,
399
+ add_child_issue: int | None = None,
400
+ ) -> tuple[str, str]:
401
+ """End-to-end: resolve the milestone, pick a machine, seed the
402
+ briefing, dispatch a ``type="milestone-chat"`` assignment.
403
+
404
+ Returns ``(assignment_id, machine_name)``. Raises ``RuntimeError`` when
405
+ the repo is unknown, the tracking issue can't be fetched or has no
406
+ milestone, no machine claims the repo, or the agent rejects the
407
+ dispatch.
408
+ """
409
+ repo_cfg = config.repo(repo_name)
410
+ if repo_cfg is None:
411
+ raise RuntimeError(f"repo {repo_name!r} not in coordinator.yml")
412
+
413
+ ctx = resolve_milestone_chat_briefing(
414
+ repo_name,
415
+ tracking_issue_number,
416
+ config,
417
+ add_child_issue=add_child_issue,
418
+ )
419
+
420
+ # Pick the machine.
421
+ if machine_override:
422
+ machine = next(
423
+ (m for m in config.machines if m.name == machine_override),
424
+ None,
425
+ )
426
+ if machine is None:
427
+ raise RuntimeError(f"machine {machine_override!r} not in coordinator.yml")
428
+ if not machine.can_work_on(repo_name):
429
+ raise RuntimeError(
430
+ f"machine {machine_override!r} does not list repo {repo_name!r}"
431
+ )
432
+ else:
433
+ picked = pick_milestone_chat_machine(config, repo_name)
434
+ if picked is None:
435
+ raise RuntimeError(
436
+ f"no machine claims repo {repo_name!r} — milestone-chat needs a "
437
+ "machine that has the repo cloned"
438
+ )
439
+ machine = picked
440
+
441
+ briefing = ctx.briefing
442
+ tracking_title = ctx.tracking_title
443
+ # #1430: deliberately not consulting models.labels — this chat is keyed
444
+ # to the milestone's tracking issue, not a single labelled work issue.
445
+ resolved_model = config.models.default
446
+ proposal = Proposal(
447
+ id=0,
448
+ machine_name=machine.name,
449
+ repo_name=repo_name,
450
+ issue_number=tracking_issue_number,
451
+ issue_title=tracking_title,
452
+ rationale="milestone-chat",
453
+ briefing=briefing,
454
+ model=resolved_model,
455
+ type="milestone-chat",
456
+ required_gates=[],
457
+ )
458
+
459
+ from coord.dispatch import dispatch_with_retry
460
+ from coord.models import Assignment
461
+ from coord.state import record_dispatched_assignment
462
+
463
+ response = dispatch_with_retry(
464
+ proposal,
465
+ config,
466
+ max_retries=config.concurrency.max_retries,
467
+ backoff_base=config.concurrency.backoff_base,
468
+ )
469
+
470
+ assignment_id = response.get("id") or uuid.uuid4().hex[:12]
471
+
472
+ asg = Assignment(
473
+ machine_name=machine.name,
474
+ repo_name=repo_name,
475
+ issue_number=tracking_issue_number,
476
+ issue_title=tracking_title,
477
+ files_allowed=[],
478
+ files_forbidden=[],
479
+ briefing=briefing,
480
+ assignment_id=assignment_id,
481
+ status="running",
482
+ dispatched_at=time.time(),
483
+ type="milestone-chat",
484
+ model=resolved_model,
485
+ )
486
+ record_dispatched_assignment(
487
+ assignment=asg,
488
+ repo_github=repo_cfg.github,
489
+ )
490
+
491
+ return assignment_id, machine.name
492
+
493
+
494
+ def dispatch_new_milestone_chat(
495
+ repo_name: str,
496
+ config: Config,
497
+ *,
498
+ seed_title: str | None = None,
499
+ seed_prompt: str | None = None,
500
+ machine_override: str | None = None,
501
+ ) -> tuple[str, str]:
502
+ """End-to-end (#1009): pick a machine, seed a brand-new-milestone
503
+ briefing, dispatch a ``type="milestone-chat"`` assignment.
504
+
505
+ Unlike :func:`dispatch_milestone_chat`, there is no existing tracking
506
+ issue or milestone to fetch — this is the "New milestone… → Chat about
507
+ this" entry point, seeded with only the repo and whatever *seed_title*/
508
+ *seed_prompt* the caller supplied. ``issue_number=0`` is used as the
509
+ sentinel for "no real issue yet" — the same established pattern
510
+ ``coord/new_issue_chat.py`` and ``coord/refine_chat.py`` (board-level
511
+ chat) already use; the TUI routes ``issue_number == 0`` rows to a
512
+ board-level tab rather than a per-issue one.
513
+
514
+ Returns ``(assignment_id, machine_name)``. Raises ``RuntimeError`` when
515
+ the repo is unknown, no machine claims it, or the agent rejects the
516
+ dispatch.
517
+ """
518
+ repo_cfg = config.repo(repo_name)
519
+ if repo_cfg is None:
520
+ raise RuntimeError(f"repo {repo_name!r} not in coordinator.yml")
521
+
522
+ if machine_override:
523
+ machine = next(
524
+ (m for m in config.machines if m.name == machine_override),
525
+ None,
526
+ )
527
+ if machine is None:
528
+ raise RuntimeError(f"machine {machine_override!r} not in coordinator.yml")
529
+ if not machine.can_work_on(repo_name):
530
+ raise RuntimeError(
531
+ f"machine {machine_override!r} does not list repo {repo_name!r}"
532
+ )
533
+ else:
534
+ picked = pick_milestone_chat_machine(config, repo_name)
535
+ if picked is None:
536
+ raise RuntimeError(
537
+ f"no machine claims repo {repo_name!r} — milestone-chat needs a "
538
+ "machine that has the repo cloned"
539
+ )
540
+ machine = picked
541
+
542
+ briefing = build_new_milestone_chat_briefing(
543
+ repo_name=repo_name,
544
+ repo_slug=repo_cfg.github,
545
+ seed_title=seed_title,
546
+ seed_prompt=seed_prompt,
547
+ )
548
+
549
+ draft_title = seed_title or "(new milestone draft)"
550
+ # #1430: deliberately not consulting models.labels — a brand-new
551
+ # milestone draft has no issue/labels to resolve against yet.
552
+ resolved_model = config.models.default
553
+ proposal = Proposal(
554
+ id=0,
555
+ machine_name=machine.name,
556
+ repo_name=repo_name,
557
+ # No tracking issue exists yet — 0 is the established sentinel
558
+ # (see coord/new_issue_chat.py, coord/refine_chat.py).
559
+ issue_number=0,
560
+ issue_title=draft_title,
561
+ rationale="milestone-chat",
562
+ briefing=briefing,
563
+ model=resolved_model,
564
+ type="milestone-chat",
565
+ required_gates=[],
566
+ )
567
+
568
+ from coord.dispatch import dispatch_with_retry
569
+ from coord.models import Assignment
570
+ from coord.state import record_dispatched_assignment
571
+
572
+ response = dispatch_with_retry(
573
+ proposal,
574
+ config,
575
+ max_retries=config.concurrency.max_retries,
576
+ backoff_base=config.concurrency.backoff_base,
577
+ )
578
+
579
+ assignment_id = response.get("id") or uuid.uuid4().hex[:12]
580
+
581
+ asg = Assignment(
582
+ machine_name=machine.name,
583
+ repo_name=repo_name,
584
+ issue_number=0,
585
+ issue_title=draft_title,
586
+ files_allowed=[],
587
+ files_forbidden=[],
588
+ briefing=briefing,
589
+ assignment_id=assignment_id,
590
+ status="running",
591
+ dispatched_at=time.time(),
592
+ type="milestone-chat",
593
+ model=resolved_model,
594
+ )
595
+ record_dispatched_assignment(
596
+ assignment=asg,
597
+ repo_github=repo_cfg.github,
598
+ )
599
+
600
+ return assignment_id, machine.name