code-coordinator 0.5.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
coord/brain.py ADDED
@@ -0,0 +1,581 @@
1
+ """Coordinator brain — gathers context and calls the configured provider to propose assignments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ import subprocess
9
+ import httpx
10
+ from typing import TYPE_CHECKING
11
+
12
+ from coord.config import Config
13
+ from coord.models import Proposal, SplitChunk, SplitProposal
14
+ from coord import github_ops
15
+
16
+ if TYPE_CHECKING:
17
+ from coord.providers.base import Provider
18
+
19
+ AGENT_PORT = 7433
20
+
21
+ _log = logging.getLogger(__name__)
22
+
23
+ SYSTEM_PROMPT = """\
24
+ You are the coordinator brain for a multi-repo, multi-machine Claude Code system.
25
+
26
+ Your job: given a set of open issues and available machines, propose which machine
27
+ should work on which issue. Each machine runs one assignment at a time.
28
+
29
+ Rules:
30
+ - Only assign a machine to a repo it has in its repo list.
31
+ - Prefer to spread work across machines rather than queue on one.
32
+ - Respect repo dependencies: if repo A depends on repo B, and repo B has an open
33
+ issue that blocks A's issue, assign B's issue first or flag the dependency.
34
+ - If two issues would touch overlapping files in the same repo, do NOT assign them
35
+ simultaneously — flag the conflict and pick the higher-priority one.
36
+ - If a machine is already busy (has a running assignment), skip it.
37
+ - Write a concise briefing for each assignment: what the worker should do, which
38
+ files are likely involved, and any constraints.
39
+
40
+ Split detection — if an issue is too large for a single worker session, propose
41
+ a split instead of an assignment. Signs an issue is too large:
42
+ - Issue body has a numbered/bulleted list with 5+ independent items
43
+ - Multiple independent files/surfaces/endpoints to change
44
+ - Title contains "migrate all", "replace remaining", "deduplicate all", etc.
45
+ Do NOT split issues that are naturally sequential or tightly coupled.
46
+
47
+ Respond with a JSON array. Each element is EITHER an assignment:
48
+ {
49
+ "type": "assignment",
50
+ "machine_name": "...",
51
+ "repo_name": "...",
52
+ "issue_number": 123,
53
+ "issue_title": "...",
54
+ "rationale": "why this machine for this issue",
55
+ "files_likely": ["path/to/file.py", ...],
56
+ "briefing": "worker instructions"
57
+ }
58
+
59
+ OR a split proposal:
60
+ {
61
+ "type": "split",
62
+ "repo_name": "...",
63
+ "issue_number": 123,
64
+ "issue_title": "...",
65
+ "rationale": "why this issue should be split",
66
+ "chunks": [
67
+ {"title": "chunk title", "scope": "what this chunk covers", "files_likely": [...]},
68
+ ...
69
+ ]
70
+ }
71
+
72
+ If there is nothing to assign (no idle machines, no open issues, or all issues
73
+ are blocked), return an empty array: []
74
+
75
+ Respond with ONLY the JSON array — no markdown fences, no commentary.\
76
+ """
77
+
78
+
79
+ def gather_context(config: Config) -> dict:
80
+ """Fetch open issues per repo and agent status per machine."""
81
+ from coord.state import upsert_open_issues
82
+
83
+ issues_by_repo: dict[str, list[dict]] = {}
84
+ for repo in config.repos:
85
+ try:
86
+ issues = github_ops.get_open_issues(repo.github)
87
+ issues_by_repo[repo.name] = issues
88
+ upsert_open_issues(repo.name, issues)
89
+ except RuntimeError:
90
+ issues_by_repo[repo.name] = []
91
+
92
+ machine_status: dict[str, dict] = {}
93
+ for machine in config.machines:
94
+ try:
95
+ resp = httpx.get(
96
+ f"http://{machine.host}:{AGENT_PORT}/status",
97
+ timeout=5,
98
+ )
99
+ machine_status[machine.name] = resp.json()
100
+ except (httpx.HTTPError, httpx.TimeoutException):
101
+ machine_status[machine.name] = {"status": "offline"}
102
+
103
+ return {
104
+ "issues_by_repo": issues_by_repo,
105
+ "machine_status": machine_status,
106
+ }
107
+
108
+
109
+ def build_prompt(config: Config, context: dict) -> str:
110
+ """Assemble the user prompt from config and gathered context."""
111
+ from coord.config import IMPLICIT_PROVIDER_TYPES
112
+ from coord.deps import blocked_repos
113
+ from coord.models import Assignment
114
+ from coord.providers import (
115
+ machines_supporting_provider,
116
+ provider_type_for,
117
+ resolve_provider_name,
118
+ )
119
+
120
+ lines: list[str] = []
121
+
122
+ lines.append("## Repos")
123
+ for repo in config.repos:
124
+ deps = f" (depends on: {', '.join(repo.depends_on)})" if repo.depends_on else ""
125
+ # #1711: name the repo's resolved provider and which machines can
126
+ # actually run it whenever that's a non-implicit backend (today:
127
+ # opencode) — steers the brain away from proposing a
128
+ # machine/provider pairing coord.dispatch.dispatch() (and
129
+ # coord.brain.filter_unroutable_provider_proposals, the
130
+ # deterministic backstop applied to whatever the brain returns)
131
+ # would refuse anyway.
132
+ provider_hint = ""
133
+ effective_provider_name = resolve_provider_name(
134
+ None, repo.provider, config.providers,
135
+ )
136
+ ptype = provider_type_for(effective_provider_name, config.providers)
137
+ if ptype not in IMPLICIT_PROVIDER_TYPES:
138
+ eligible = machines_supporting_provider(
139
+ config.machines, effective_provider_name, config.providers,
140
+ )
141
+ if eligible:
142
+ provider_hint = (
143
+ f" [provider={effective_provider_name} — ONLY propose "
144
+ f"machines: {', '.join(eligible)}]"
145
+ )
146
+ else:
147
+ provider_hint = (
148
+ f" [provider={effective_provider_name} — NO machine can "
149
+ "run this yet; do not propose an assignment here]"
150
+ )
151
+ lines.append(f"- {repo.name} ({repo.github}){deps}{provider_hint}")
152
+
153
+ lines.append("")
154
+ from coord.machine_pause import paused_set
155
+ paused = paused_set(config.machines)
156
+ lines.append("## Machines")
157
+ for machine in config.machines:
158
+ caps = ", ".join(machine.capabilities) if machine.capabilities else "none"
159
+ repos = ", ".join(machine.repos) if machine.repos else "none"
160
+ status = context["machine_status"].get(machine.name, {})
161
+ if machine.name in paused:
162
+ # Routing-pause: do not propose work for this machine until
163
+ # the user runs `coord unpause`. Reachability is unchanged.
164
+ state = "paused (do not propose work)"
165
+ elif status.get("status") == "offline":
166
+ state = "offline"
167
+ elif status.get("assignment"):
168
+ state = f"busy (working on: {status['assignment'].get('issue_title', '?')})"
169
+ else:
170
+ state = "idle"
171
+ lines.append(f"- {machine.name} @ {machine.host} [{state}]")
172
+ lines.append(f" capabilities: {caps}")
173
+ lines.append(f" repos: {repos}")
174
+
175
+ lines.append("")
176
+ lines.append("## Open Issues")
177
+ for repo_name, issues in context["issues_by_repo"].items():
178
+ if not issues:
179
+ lines.append(f"### {repo_name}: (no open issues)")
180
+ continue
181
+ lines.append(f"### {repo_name}")
182
+ for issue in issues:
183
+ labels = ", ".join(l.get("name", "") for l in issue.get("labels", []))
184
+ label_str = f" [{labels}]" if labels else ""
185
+ lines.append(f"- #{issue['number']}: {issue['title']}{label_str}")
186
+ body = (issue.get("body") or "").strip()
187
+ if body:
188
+ preview = body[:150]
189
+ if len(body) > 150:
190
+ preview += "..."
191
+ lines.append(f" {preview}")
192
+
193
+ # Build active assignments from machine status to compute blocked repos
194
+ active_assignments: list[Assignment] = []
195
+ for machine_name, status in context["machine_status"].items():
196
+ for entry in status.get("active", []):
197
+ spec = entry.get("spec", {})
198
+ active_assignments.append(Assignment(
199
+ machine_name=machine_name,
200
+ repo_name=spec.get("repo_name", ""),
201
+ issue_number=spec.get("issue_number", 0),
202
+ issue_title=spec.get("issue_title", ""),
203
+ status="running",
204
+ ))
205
+
206
+ blocked = blocked_repos(config.repos, active_assignments)
207
+ if blocked:
208
+ lines.append("")
209
+ lines.append("## Blocked Repos (DO NOT assign work here)")
210
+ for repo_name, reasons in blocked.items():
211
+ lines.append(f"### {repo_name} — BLOCKED")
212
+ for reason in reasons:
213
+ lines.append(f" - {reason}")
214
+
215
+ return "\n".join(lines)
216
+
217
+
218
+ def _resolve_default_provider(config: Config) -> "Provider":
219
+ """Instantiate the coordinator's default provider from *config*.
220
+
221
+ Thin wrapper around :func:`coord.providers.resolve_default_provider` —
222
+ all logic (precedence chain, ``human_attended_only`` guard, fallback to
223
+ :class:`~coord.providers.claude.ClaudeProvider`) lives there so that brain
224
+ planning and the dashboard assistant share a single implementation.
225
+
226
+ Args:
227
+ config: The coordinator config.
228
+
229
+ Returns:
230
+ A ready-to-use :class:`~coord.providers.base.Provider` instance whose
231
+ ``capabilities().human_attended_only`` is ``False``.
232
+
233
+ Raises:
234
+ ValueError: When the configured default provider reports
235
+ ``capabilities().human_attended_only=True``. Brain planning is
236
+ an unattended path and must not route through a human-attended
237
+ backend such as :class:`~coord.providers.claude_pty.ClaudePtyProvider`.
238
+ """
239
+ from coord.providers import resolve_default_provider # noqa: PLC0415
240
+
241
+ return resolve_default_provider(config.providers, config.models)
242
+
243
+
244
+ def call_claude(system: str, user: str, *, provider: "Provider | None" = None) -> str:
245
+ """Run the configured provider in one-shot mode and return the text response.
246
+
247
+ Builds the subprocess argv via ``provider.oneshot_command()`` so that
248
+ brain planning honours the coordinator's configured backend rather than
249
+ hard-coding ``claude``.
250
+
251
+ When *provider* is ``None`` a :class:`~coord.providers.claude.ClaudeProvider`
252
+ is used — matching the historical behaviour (``claude -p``). Callers
253
+ that want to honour the coordinator's configured backend should pass
254
+ the result of :func:`_resolve_default_provider`.
255
+
256
+ Args:
257
+ system: The system prompt for the brain's planning call.
258
+ user: The user message (piped to the subprocess via stdin).
259
+ provider: Provider whose ``oneshot_command()`` is called to build
260
+ the argv. ``None`` falls back to
261
+ :class:`~coord.providers.claude.ClaudeProvider`.
262
+
263
+ Returns:
264
+ The text response string.
265
+
266
+ Raises:
267
+ RuntimeError: When the subprocess exits with a non-zero return code.
268
+ """
269
+ if provider is None:
270
+ from coord.providers.claude import ClaudeProvider # noqa: PLC0415
271
+ provider = ClaudeProvider()
272
+
273
+ cmd = provider.oneshot_command(system_prompt=system, output_format="json")
274
+ result = subprocess.run(
275
+ cmd,
276
+ input=user,
277
+ capture_output=True,
278
+ text=True,
279
+ timeout=300,
280
+ )
281
+ if result.returncode != 0:
282
+ raise RuntimeError(
283
+ f"brain provider call failed (exit {result.returncode}): {result.stderr.strip()}"
284
+ )
285
+
286
+ # Try to extract the "result" field from a claude -p JSON envelope.
287
+ # Falls back to raw stdout for providers that don't emit this shape
288
+ # (e.g. OpenCodeProvider, whose output is unstructured).
289
+ try:
290
+ outer = json.loads(result.stdout)
291
+ if isinstance(outer, dict) and "result" in outer:
292
+ return outer["result"]
293
+ except (json.JSONDecodeError, ValueError):
294
+ pass
295
+ return result.stdout
296
+
297
+
298
+ def _strip_fences(text: str) -> str:
299
+ cleaned = text.strip()
300
+ fence = re.match(r"^```(?:json)?\s*\n(.*?)```\s*$", cleaned, re.DOTALL)
301
+ return fence.group(1).strip() if fence else cleaned
302
+
303
+
304
+ def parse_proposals(text: str) -> list[Proposal]:
305
+ """Parse the JSON response from Claude into Proposal objects."""
306
+ data = json.loads(_strip_fences(text))
307
+ if not isinstance(data, list):
308
+ raise ValueError(f"Expected JSON array of proposals, got {type(data).__name__}")
309
+
310
+ proposals = []
311
+ for i, item in enumerate(data):
312
+ if item.get("type") == "split":
313
+ continue
314
+ proposals.append(Proposal(
315
+ id=i + 1,
316
+ machine_name=item["machine_name"],
317
+ repo_name=item["repo_name"],
318
+ issue_number=item["issue_number"],
319
+ issue_title=item["issue_title"],
320
+ rationale=item.get("rationale", ""),
321
+ files_likely=item.get("files_likely", []),
322
+ briefing=item.get("briefing", ""),
323
+ ))
324
+ return proposals
325
+
326
+
327
+ def resolve_required_gates(
328
+ proposals: list[Proposal],
329
+ config: Config,
330
+ issues_by_repo: dict[str, list[dict]],
331
+ ) -> None:
332
+ """Resolve required_gates for each proposal from config.pipeline.labels.
333
+
334
+ Mutates proposals in place: for each proposal, looks up the issue in
335
+ ``issues_by_repo`` to get its GitHub labels, then checks each label against
336
+ ``config.pipeline.labels``. The first matching label wins. If no label
337
+ matches, ``required_gates`` is left unchanged (empty → caller falls back to
338
+ ``config.pipeline.default_gates`` at dispatch/pipeline-view time).
339
+ """
340
+ if not config.pipeline.labels:
341
+ return # no label overrides configured, nothing to do
342
+
343
+ for proposal in proposals:
344
+ repo_issues = issues_by_repo.get(proposal.repo_name, [])
345
+ issue = next(
346
+ (iss for iss in repo_issues if iss.get("number") == proposal.issue_number),
347
+ None,
348
+ )
349
+ if issue is None:
350
+ continue
351
+ issue_labels: list[str] = [
352
+ lbl.get("name", "") for lbl in (issue.get("labels") or [])
353
+ ]
354
+ for lbl in issue_labels:
355
+ if lbl in config.pipeline.labels:
356
+ proposal.required_gates = list(config.pipeline.labels[lbl])
357
+ break
358
+
359
+
360
+ def resolve_models(
361
+ proposals: list[Proposal],
362
+ config: Config,
363
+ issues_by_repo: dict[str, list[dict]],
364
+ ) -> None:
365
+ """Resolve proposal.model for each work proposal from config.models.labels.
366
+
367
+ #1430: mirrors :func:`resolve_required_gates`'s shape (walk each
368
+ proposal's issue, resolve its labels, set a field). Precedence no
369
+ longer matches, though: #1633 found that issue-label order (which
370
+ GitHub controls, not this repo) made ``tier:small``/``tier:large``
371
+ silent no-ops on any issue that also carried a type label, so
372
+ :meth:`coord.config.ModelsConfig.model_for_labels_with_reason` now
373
+ resolves deterministically instead — ``tier:*`` entries first, then
374
+ all other entries, ties within each group broken by ``models.labels``'s
375
+ own declaration order in ``coordinator.yml``. ``resolve_required_gates``
376
+ below still uses the old issue-label-order convention for
377
+ ``pipeline.labels``; that's a separate, still-open instance of the same
378
+ bug class (tracked outside #1633's scope) and not something callers of
379
+ this function should assume matches.
380
+
381
+ Only ``type="work"`` proposals are touched. ``_apply_require_plan``
382
+ (called before this, in :func:`propose`) may already have upgraded a
383
+ proposal to ``type="plan"`` — plan workers are read-only/cheap and must
384
+ not inherit a ``tier:large`` -> opus routing meant for the eventual work
385
+ dispatch, so plan proposals are left on ``models.default`` here.
386
+
387
+ Leaves ``proposal.model`` unset (``None``) when no label matches, so the
388
+ existing ``if not p.model: p.model = cfg.models.default`` fallback in
389
+ ``coord approve`` (``coord/commands/dispatch.py``) keeps working
390
+ unchanged. Never overrides a model already set on the proposal (the
391
+ brain's own JSON output, or a human editing saved proposals before
392
+ approving).
393
+
394
+ #1889: also stamps ``proposal.issue_labels`` when only
395
+ ``config.providers.labels`` (not ``config.models.labels``) is
396
+ configured, so :func:`filter_unroutable_provider_proposals` (below) can
397
+ resolve ``providers.labels`` too — a provider-only label eval must not
398
+ require ``models.labels`` to also be configured just to get its issue's
399
+ labels threaded through. No extra GitHub call either way: both concerns
400
+ read the same already-fetched ``issues_by_repo``.
401
+ """
402
+ if not config.models.labels and not config.providers.labels:
403
+ return # no label overrides of either kind configured, nothing to do
404
+
405
+ for proposal in proposals:
406
+ if proposal.type != "work":
407
+ continue
408
+ repo_issues = issues_by_repo.get(proposal.repo_name, [])
409
+ issue = next(
410
+ (iss for iss in repo_issues if iss.get("number") == proposal.issue_number),
411
+ None,
412
+ )
413
+ if issue is None:
414
+ continue
415
+ issue_labels: list[str] = [
416
+ lbl.get("name", "") for lbl in (issue.get("labels") or [])
417
+ ]
418
+ proposal.issue_labels = issue_labels
419
+ if proposal.model:
420
+ continue
421
+ resolved = config.models.model_for_labels(issue_labels)
422
+ if resolved:
423
+ proposal.model = resolved
424
+
425
+
426
+ def parse_split_proposals(text: str) -> list[SplitProposal]:
427
+ """Parse split proposals from the brain's JSON response."""
428
+ data = json.loads(_strip_fences(text))
429
+ if not isinstance(data, list):
430
+ return []
431
+
432
+ splits = []
433
+ for i, item in enumerate(data):
434
+ if item.get("type") != "split":
435
+ continue
436
+ chunks = [
437
+ SplitChunk(
438
+ title=c["title"],
439
+ scope=c.get("scope", ""),
440
+ files_likely=c.get("files_likely", []),
441
+ )
442
+ for c in item.get("chunks", [])
443
+ ]
444
+ splits.append(SplitProposal(
445
+ id=i + 1,
446
+ repo_name=item["repo_name"],
447
+ issue_number=item["issue_number"],
448
+ issue_title=item["issue_title"],
449
+ rationale=item.get("rationale", ""),
450
+ chunks=chunks,
451
+ ))
452
+ return splits
453
+
454
+
455
+ def _annotate_large_proposals(proposals: list[Proposal], config: Config) -> None:
456
+ """Append a split-suggestion note to proposals that exceed the file threshold.
457
+
458
+ Mutates proposals in place. The note is appended to ``rationale`` so it
459
+ surfaces in ``coord plan`` output without cluttering the briefing itself.
460
+ """
461
+ threshold = config.dispatch.max_files_per_worker
462
+ for p in proposals:
463
+ if len(p.files_likely) > threshold:
464
+ note = (
465
+ f" [⚠ {len(p.files_likely)} files > threshold {threshold} — "
466
+ "consider splitting via coord split]"
467
+ )
468
+ if note not in p.rationale:
469
+ p.rationale += note
470
+
471
+
472
+ def _apply_require_plan(proposals: list[Proposal], config: Config) -> None:
473
+ """When dispatch.require_plan is true, upgrade all work proposals to plan type.
474
+
475
+ Mutates proposals in place. Only work proposals are affected — review, smoke,
476
+ and already-typed plan proposals are left unchanged.
477
+ """
478
+ if not config.dispatch.require_plan:
479
+ return
480
+ for p in proposals:
481
+ if p.type == "work":
482
+ p.type = "plan"
483
+
484
+
485
+ def filter_unroutable_provider_proposals(
486
+ proposals: list[Proposal], config: Config,
487
+ ) -> tuple[list[Proposal], list[tuple[Proposal, str]]]:
488
+ """#1711: drop any brain-proposed assignment whose target machine
489
+ cannot run the repo's resolved provider.
490
+
491
+ The brain is a free-text LLM planner (see ``SYSTEM_PROMPT``) — nothing
492
+ stops it proposing ``machine_name="laptop"`` for a repo whose resolved
493
+ provider is ``opencode`` when ``laptop`` never declared
494
+ ``provider:opencode`` in ``coordinator.yml``.
495
+ :func:`coord.dispatch.dispatch` would refuse that exact combination at
496
+ dispatch time anyway (:func:`coord.providers.
497
+ guard_provider_machine_capability` — the same #1711 gate), but only
498
+ once the proposal has already been shown to the operator and approved.
499
+ This runs the identical deterministic check at planning time instead,
500
+ so ``coord plan`` never shows a proposal ``coord approve`` would
501
+ immediately refuse.
502
+
503
+ A brain-authored :class:`~coord.models.Proposal` never carries a
504
+ per-proposal ``provider`` override — the JSON schema documented in
505
+ ``SYSTEM_PROMPT`` has no such field, only ``machine_name``/
506
+ ``repo_name``/etc — so the effective provider for each proposal comes
507
+ from ``Repo.provider`` / ``providers.default`` alone, exactly like any
508
+ other dispatch with no explicit ``--provider``.
509
+
510
+ An unresolvable ``machine_name`` (typo, or a machine removed from
511
+ config after the brain call started) is left in ``kept`` — that's a
512
+ different, pre-existing failure mode (``coord approve``'s own "Unknown
513
+ machine" error), not this filter's concern.
514
+
515
+ Returns:
516
+ ``(kept, dropped)`` — ``dropped`` pairs each rejected proposal with
517
+ the human-readable refusal reason (the same message
518
+ :func:`~coord.providers.guard_provider_machine_capability` would
519
+ raise at dispatch time), so a caller can report exactly why a
520
+ proposal disappeared instead of it just silently not showing up.
521
+ """
522
+ from coord.providers import ( # noqa: PLC0415
523
+ guard_provider_machine_capability,
524
+ resolve_provider_name,
525
+ )
526
+
527
+ kept: list[Proposal] = []
528
+ dropped: list[tuple[Proposal, str]] = []
529
+ for p in proposals:
530
+ machine = next((m for m in config.machines if m.name == p.machine_name), None)
531
+ if machine is None:
532
+ kept.append(p)
533
+ continue
534
+ repo = config.repo(p.repo_name)
535
+ # #1889: providers.labels, gated to type="work" like every other
536
+ # dispatch site — `p.issue_labels` is stamped by `resolve_models`
537
+ # above (in the same `propose()` call, before this filter runs) for
538
+ # both models.labels and providers.labels, so this reflects the
539
+ # same labels `coord approve`/`dispatch()` will resolve against.
540
+ effective_provider_name = resolve_provider_name(
541
+ getattr(p, "provider", None),
542
+ repo.provider if repo is not None else None,
543
+ config.providers,
544
+ issue_labels=p.issue_labels if p.type == "work" else None,
545
+ )
546
+ try:
547
+ guard_provider_machine_capability(
548
+ provider_name=effective_provider_name,
549
+ machine=machine,
550
+ all_machines=config.machines,
551
+ providers_cfg=config.providers,
552
+ where="coord plan",
553
+ )
554
+ except ValueError as e:
555
+ _log.warning("coord plan: dropping unroutable proposal: %s", e)
556
+ dropped.append((p, str(e)))
557
+ continue
558
+ kept.append(p)
559
+ return kept, dropped
560
+
561
+
562
+ def propose(config: Config) -> tuple[list[Proposal], list[SplitProposal]]:
563
+ """Full brain cycle: gather context, call the provider, return proposals and splits."""
564
+ provider = _resolve_default_provider(config)
565
+ context = gather_context(config)
566
+ prompt = build_prompt(config, context)
567
+ response = call_claude(SYSTEM_PROMPT, prompt, provider=provider)
568
+ proposals = parse_proposals(response)
569
+ _apply_require_plan(proposals, config)
570
+ resolve_required_gates(proposals, config, context["issues_by_repo"])
571
+ resolve_models(proposals, config, context["issues_by_repo"])
572
+ _annotate_large_proposals(proposals, config)
573
+ # #1711: never return a proposal `coord approve` would immediately
574
+ # refuse for lacking the resolved provider's capability — see
575
+ # filter_unroutable_provider_proposals's docstring. Dropped proposals
576
+ # are logged (above) rather than surfaced here; `propose()` is used by
577
+ # both `coord plan`'s CLI wrapper (which reports drops itself, see
578
+ # coord.commands.dispatch.plan) and non-CLI callers (e.g. the
579
+ # dashboard) that have no click.echo to report through.
580
+ proposals, _dropped = filter_unroutable_provider_proposals(proposals, config)
581
+ return proposals, parse_split_proposals(response)