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/refine_chat.py ADDED
@@ -0,0 +1,485 @@
1
+ """#264: dispatch a `type="refinement"` chat session for an issue.
2
+
3
+ Used by `coord refine-chat <repo> <issue>` (the CLI command shelled out by the
4
+ TUI's right-click `Refine with chat` action). Builds a seed briefing that
5
+ pre-loads the worker with the issue body, recent comments, the repo's
6
+ `CLAUDE.md`, and a bounded file-tree snapshot — same context shape a normal
7
+ worker briefing would carry, scoped down to read-only refinement use.
8
+
9
+ Also used by `coord refine-board <repo>` (#316 Phase C) for a board-level
10
+ (no-issue) refinement chat where the developer explores ideas without being
11
+ tied to a specific issue. These use ``issue_number=0`` as the sentinel value.
12
+
13
+ The session itself runs as a `type="refinement"` assignment on an agent
14
+ server. Tools are restricted to `Read` (see `coord/agent.py`); the developer
15
+ drives the conversation via `POST /inject/{id}` from the TUI.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import time
21
+ import uuid
22
+ import subprocess
23
+ from pathlib import Path
24
+
25
+ from coord import github_ops
26
+ from coord.config import Config
27
+ from coord.models import Machine, Proposal, Repo
28
+
29
+ # Soft caps so the seed briefing stays bounded — even on repos with sprawling
30
+ # trees or chatty issues. These translate to <50 KiB of prompt context in
31
+ # typical use, which the 1h-ephemeral cache then makes nearly free on reopen.
32
+ MAX_COMMENT_BODIES = 5
33
+ MAX_COMMENT_BODY_CHARS = 800
34
+ MAX_FILE_TREE_LINES = 200
35
+ MAX_FILE_TREE_DEPTH = 2
36
+ MAX_CLAUDE_MD_CHARS = 8000
37
+
38
+
39
+ def pick_refinement_machine(cfg: Config, repo: str) -> Machine | None:
40
+ """Pick a machine to run the refinement session on.
41
+
42
+ Returns the first reachable machine that lists *repo* and is not
43
+ paused (via `coord pause <name>`). Refinement is read-only and
44
+ short-lived, so we don't need the freshness / capacity weighting
45
+ `coord plan` uses for work dispatch — any qualified machine works.
46
+ Returns `None` when no machine claims the repo.
47
+ """
48
+ from coord.machine_pause import paused_set
49
+ paused = paused_set(cfg.machines)
50
+ for m in cfg.machines:
51
+ if (
52
+ m.can_work_on(repo)
53
+ and m.repo_path(repo) is not None
54
+ and m.name not in paused
55
+ ):
56
+ return m
57
+ return None
58
+
59
+
60
+ def _fetch_recent_comments(slug: str, issue_number: int) -> list[dict]:
61
+ """Best-effort fetch of an issue's recent comments via `gh`.
62
+
63
+ Returns an empty list on any failure — refinement still works without
64
+ comment context, the seed just gets a "(none)" stub.
65
+ """
66
+ try:
67
+ raw = github_ops._gh(
68
+ "issue", "view", str(issue_number),
69
+ "--repo", slug,
70
+ "--json", "comments",
71
+ )
72
+ data = json.loads(raw)
73
+ comments = data.get("comments") or []
74
+ if not isinstance(comments, list):
75
+ return []
76
+ return comments[-MAX_COMMENT_BODIES:]
77
+ except (RuntimeError, ValueError, json.JSONDecodeError):
78
+ return []
79
+
80
+
81
+ def _read_claude_md(repo_root: Path) -> str:
82
+ """Read the repo's `CLAUDE.md` (top-level) and clamp to MAX_CLAUDE_MD_CHARS.
83
+
84
+ Returns an empty string when the file doesn't exist or is unreadable.
85
+ """
86
+ path = repo_root / "CLAUDE.md"
87
+ try:
88
+ text = path.read_text(encoding="utf-8", errors="replace")
89
+ except (OSError, FileNotFoundError):
90
+ return ""
91
+ if len(text) > MAX_CLAUDE_MD_CHARS:
92
+ return text[:MAX_CLAUDE_MD_CHARS] + "\n…[truncated]"
93
+ return text
94
+
95
+
96
+ def _file_tree_snapshot(repo_root: Path) -> str:
97
+ """Capture a shallow file-tree snapshot for the seed.
98
+
99
+ Uses `find` with a depth cap and clamps the output to
100
+ MAX_FILE_TREE_LINES. Excludes the obvious noise (`.git`, build dirs,
101
+ deps) so the snapshot reads as a project map rather than a `node_modules`
102
+ dump. Returns "(no tree available)" when the shell-out fails.
103
+ """
104
+ # Use `-prune` so the excluded directories disappear from the listing
105
+ # entirely (not just their contents) — `target/` showing up as a bare
106
+ # line with no children reads as noise, not signal.
107
+ excluded = (".git", "target", "node_modules", ".venv", "__pycache__")
108
+ prune_args: list[str] = []
109
+ for i, name in enumerate(excluded):
110
+ if i > 0:
111
+ prune_args.extend(["-o"])
112
+ prune_args.extend(["-name", name])
113
+ try:
114
+ result = subprocess.run(
115
+ [
116
+ "find", str(repo_root),
117
+ "-maxdepth", str(MAX_FILE_TREE_DEPTH),
118
+ "(", *prune_args, ")", "-prune",
119
+ "-o", "-print",
120
+ ],
121
+ capture_output=True,
122
+ text=True,
123
+ timeout=10,
124
+ )
125
+ if result.returncode != 0:
126
+ return "(no tree available)"
127
+ except (OSError, subprocess.TimeoutExpired):
128
+ return "(no tree available)"
129
+ lines = result.stdout.splitlines()
130
+ # Drop the repo root itself for readability, then make paths repo-relative.
131
+ rel: list[str] = []
132
+ root_str = str(repo_root)
133
+ for line in lines:
134
+ if not line or line == root_str:
135
+ continue
136
+ rel.append(line[len(root_str) + 1:] if line.startswith(root_str + "/") else line)
137
+ rel.sort()
138
+ if len(rel) > MAX_FILE_TREE_LINES:
139
+ truncated = len(rel) - MAX_FILE_TREE_LINES
140
+ rel = rel[:MAX_FILE_TREE_LINES]
141
+ rel.append(f"…[{truncated} more entries truncated]")
142
+ return "\n".join(rel) if rel else "(empty repo)"
143
+
144
+
145
+ def build_refinement_briefing(
146
+ *,
147
+ repo_slug: str,
148
+ issue_number: int,
149
+ issue_title: str,
150
+ issue_body: str,
151
+ comments: list[dict],
152
+ claude_md: str,
153
+ file_tree: str,
154
+ ) -> str:
155
+ """Compose the seed briefing the worker sees as its first user message.
156
+
157
+ The agent's `REFINEMENT_SYSTEM_PROMPT` already tells it how to behave;
158
+ this just packs the conversation context.
159
+ """
160
+ parts: list[str] = []
161
+ parts.append(f"=== Refinement context for {repo_slug}#{issue_number}: {issue_title} ===\n")
162
+ parts.append("ISSUE BODY:")
163
+ parts.append(issue_body.strip() if issue_body.strip() else "(empty)")
164
+ parts.append("")
165
+ parts.append("RECENT COMMENTS:")
166
+ if comments:
167
+ for c in comments:
168
+ author = (c.get("author") or {}).get("login") or "?"
169
+ body = (c.get("body") or "").strip()
170
+ if len(body) > MAX_COMMENT_BODY_CHARS:
171
+ body = body[:MAX_COMMENT_BODY_CHARS] + "…[truncated]"
172
+ parts.append(f"--- @{author}")
173
+ parts.append(body if body else "(empty)")
174
+ else:
175
+ parts.append("(none)")
176
+ parts.append("")
177
+ parts.append("PROJECT CLAUDE.md:")
178
+ parts.append(claude_md.strip() if claude_md.strip() else "(not found)")
179
+ parts.append("")
180
+ parts.append("REPO FILE TREE (depth 2, common build dirs excluded):")
181
+ parts.append(file_tree)
182
+ parts.append("")
183
+ parts.append("---")
184
+ parts.append(
185
+ "The developer will now ask you questions about this issue. "
186
+ "Ask focused clarifying questions back. Use the Read tool to peek at "
187
+ "files when the conversation calls for it. Keep replies short."
188
+ )
189
+ return "\n".join(parts)
190
+
191
+
192
+ def dispatch_refinement(
193
+ *,
194
+ cfg: Config,
195
+ repo_cfg: Repo,
196
+ repo: str,
197
+ issue_number: int,
198
+ machine_override: str | None = None,
199
+ ) -> tuple[str, str]:
200
+ """End-to-end: pick a machine, seed the briefing, dispatch a refinement
201
+ assignment. Returns ``(assignment_id, machine_name)``.
202
+
203
+ Raises ``RuntimeError`` when no machine claims the repo, when the issue
204
+ can't be fetched, or when the agent rejects the dispatch.
205
+ """
206
+ # Pick the machine.
207
+ if machine_override:
208
+ machine = next(
209
+ (m for m in cfg.machines if m.name == machine_override),
210
+ None,
211
+ )
212
+ if machine is None:
213
+ raise RuntimeError(
214
+ f"machine {machine_override!r} not in coordinator.yml"
215
+ )
216
+ if not machine.can_work_on(repo):
217
+ raise RuntimeError(
218
+ f"machine {machine_override!r} does not list repo {repo!r}"
219
+ )
220
+ else:
221
+ picked = pick_refinement_machine(cfg, repo)
222
+ if picked is None:
223
+ raise RuntimeError(
224
+ f"no machine claims repo {repo!r} — refinement needs a "
225
+ f"machine that has the repo cloned to read CLAUDE.md"
226
+ )
227
+ machine = picked
228
+
229
+ repo_path = machine.repo_path(repo)
230
+ if repo_path is None:
231
+ raise RuntimeError(
232
+ f"machine {machine.name!r} has no resolved path for repo {repo!r}"
233
+ )
234
+
235
+ # Fetch the issue + recent comments.
236
+ try:
237
+ issue_data = github_ops.get_issue(repo_cfg.github, issue_number)
238
+ except RuntimeError as exc:
239
+ raise RuntimeError(f"could not fetch issue #{issue_number}: {exc}") from exc
240
+ issue_title = issue_data.get("title") or f"Issue #{issue_number}"
241
+ issue_body = issue_data.get("body") or ""
242
+ comments = _fetch_recent_comments(repo_cfg.github, issue_number)
243
+
244
+ # Read the LOCAL CLAUDE.md + file tree (from the coordinator's view of the
245
+ # repo, which may differ from the agent's checkout — but it's the source
246
+ # of truth for project conventions in the seed).
247
+ repo_root = Path(repo_path).expanduser()
248
+ claude_md = _read_claude_md(repo_root)
249
+ file_tree = _file_tree_snapshot(repo_root)
250
+
251
+ briefing = build_refinement_briefing(
252
+ repo_slug=repo_cfg.github,
253
+ issue_number=issue_number,
254
+ issue_title=issue_title,
255
+ issue_body=issue_body,
256
+ comments=comments,
257
+ claude_md=claude_md,
258
+ file_tree=file_tree,
259
+ )
260
+
261
+ # Build the Proposal — model resolves through models.versions so the
262
+ # wire payload carries an exact id (the same path work dispatch uses).
263
+ # #1430: deliberately not consulting models.labels — this is a
264
+ # conversational refinement chat, often used to help shape the issue
265
+ # (including its labels) in the first place, not the eventual work
266
+ # dispatch that follows.
267
+ resolved_model = cfg.models.default
268
+ proposal = Proposal(
269
+ id=0,
270
+ machine_name=machine.name,
271
+ repo_name=repo,
272
+ issue_number=issue_number,
273
+ issue_title=issue_title,
274
+ rationale="refine-chat",
275
+ briefing=briefing,
276
+ model=resolved_model,
277
+ type="refinement",
278
+ required_gates=[],
279
+ )
280
+
281
+ from coord.dispatch import dispatch_with_retry
282
+ from coord.state import record_dispatched_assignment
283
+ from coord.models import Assignment
284
+
285
+ response = dispatch_with_retry(
286
+ proposal, cfg,
287
+ max_retries=cfg.concurrency.max_retries,
288
+ backoff_base=cfg.concurrency.backoff_base,
289
+ )
290
+
291
+ assignment_id = response.get("id") or uuid.uuid4().hex[:12]
292
+
293
+ # Persist the assignment row so the TUI can find it by polling the DB.
294
+ asg = Assignment(
295
+ machine_name=machine.name,
296
+ repo_name=repo,
297
+ issue_number=issue_number,
298
+ issue_title=issue_title,
299
+ files_allowed=[],
300
+ files_forbidden=[],
301
+ briefing=briefing,
302
+ assignment_id=assignment_id,
303
+ status="running",
304
+ dispatched_at=time.time(),
305
+ type="refinement",
306
+ model=resolved_model,
307
+ )
308
+ record_dispatched_assignment(
309
+ assignment=asg,
310
+ repo_github=repo_cfg.github,
311
+ )
312
+
313
+ return assignment_id, machine.name
314
+
315
+
316
+ # ─── #316 Phase C: board-level (no-issue) refinement chat ─────────────────────
317
+
318
+
319
+ def build_board_refinement_briefing(
320
+ *,
321
+ repo_slug: str,
322
+ claude_md: str,
323
+ file_tree: str,
324
+ open_issue_titles: list[str],
325
+ ) -> str:
326
+ """Compose the seed briefing for a board-level refinement chat.
327
+
328
+ Unlike the issue-specific refinement, there is no issue body or comments —
329
+ the session is open-ended exploration. The worker is seeded with the repo's
330
+ CLAUDE.md, the file tree, and the current open issue titles (for awareness
331
+ of what's already tracked) so the developer can brainstorm freely.
332
+ """
333
+ parts: list[str] = []
334
+ parts.append(f"=== Board-level refinement for {repo_slug} ===\n")
335
+ parts.append("This is an open-ended brainstorming session for the repository.")
336
+ parts.append("There is no specific issue — the developer wants to explore ideas,")
337
+ parts.append("discuss the codebase, or draft new work items.\n")
338
+ parts.append("PROJECT CLAUDE.md:")
339
+ parts.append(claude_md.strip() if claude_md.strip() else "(not found)")
340
+ parts.append("")
341
+ parts.append("REPO FILE TREE (depth 2, common build dirs excluded):")
342
+ parts.append(file_tree)
343
+ parts.append("")
344
+ if open_issue_titles:
345
+ parts.append("CURRENT OPEN ISSUES (for context — avoid duplicates):")
346
+ for title in open_issue_titles:
347
+ parts.append(f" - {title}")
348
+ else:
349
+ parts.append("CURRENT OPEN ISSUES: (none)")
350
+ parts.append("")
351
+ parts.append("---")
352
+ parts.append(
353
+ "The developer will start the conversation. Ask focused questions, "
354
+ "explore ideas, and help articulate intent. Use the Read tool to peek "
355
+ "at files when relevant. Keep replies concise."
356
+ )
357
+ return "\n".join(parts)
358
+
359
+
360
+ def dispatch_board_refinement(
361
+ *,
362
+ cfg: Config,
363
+ repo: str,
364
+ machine_override: str | None = None,
365
+ ) -> tuple[str, str]:
366
+ """Dispatch a board-level (no-issue) refinement chat for *repo*.
367
+
368
+ Uses ``issue_number=0`` as the sentinel value so the TUI's
369
+ ``chat_is_board_chat()`` can distinguish board chats from issue-specific
370
+ ones. Returns ``(assignment_id, machine_name)``.
371
+
372
+ Raises ``RuntimeError`` when the repo is unknown, no machine claims it,
373
+ or the agent rejects the dispatch.
374
+ """
375
+ repo_cfg = cfg.repo(repo)
376
+ if repo_cfg is None:
377
+ raise RuntimeError(f"repo {repo!r} not in coordinator.yml")
378
+
379
+ # Pick the machine.
380
+ if machine_override:
381
+ machine = next(
382
+ (m for m in cfg.machines if m.name == machine_override),
383
+ None,
384
+ )
385
+ if machine is None:
386
+ raise RuntimeError(f"machine {machine_override!r} not in coordinator.yml")
387
+ if not machine.can_work_on(repo):
388
+ raise RuntimeError(
389
+ f"machine {machine_override!r} does not list repo {repo!r}"
390
+ )
391
+ else:
392
+ picked = pick_refinement_machine(cfg, repo)
393
+ if picked is None:
394
+ raise RuntimeError(
395
+ f"no machine claims repo {repo!r} — refine-board needs a "
396
+ "machine that has the repo cloned to read CLAUDE.md"
397
+ )
398
+ machine = picked
399
+
400
+ repo_path = machine.repo_path(repo)
401
+ if repo_path is None:
402
+ raise RuntimeError(
403
+ f"machine {machine.name!r} has no resolved path for repo {repo!r}"
404
+ )
405
+
406
+ repo_root = Path(repo_path).expanduser()
407
+ claude_md = _read_claude_md(repo_root)
408
+ file_tree = _file_tree_snapshot(repo_root)
409
+
410
+ # Fetch open issue titles for near-duplicate awareness (best-effort).
411
+ open_titles: list[str] = []
412
+ try:
413
+ raw = github_ops._gh(
414
+ "issue", "list",
415
+ "--repo", repo_cfg.github,
416
+ "--state", "open",
417
+ "--limit", "20",
418
+ "--json", "title",
419
+ )
420
+ data = json.loads(raw)
421
+ open_titles = [item.get("title", "") for item in data if item.get("title")]
422
+ except Exception:
423
+ pass # Not critical — session still useful without the list.
424
+
425
+ briefing = build_board_refinement_briefing(
426
+ repo_slug=repo_cfg.github,
427
+ claude_md=claude_md,
428
+ file_tree=file_tree,
429
+ open_issue_titles=open_titles,
430
+ )
431
+
432
+ # #1430: deliberately not consulting models.labels — a board-level
433
+ # refinement chat has no single issue (issue_number=0 sentinel below),
434
+ # so there's no label to resolve against.
435
+ resolved_model = cfg.models.default
436
+ issue_title = f"Board refinement for {repo}"
437
+
438
+ # #316: issue_number=0 is the sentinel for board-level chats. The TUI
439
+ # uses `w.issue_number == 0` to route to the Board Chat tab rather than
440
+ # the Pipeline Refinement tab.
441
+ from coord.dispatch import dispatch_with_retry
442
+ from coord.state import record_dispatched_assignment
443
+ from coord.models import Assignment, Proposal
444
+
445
+ proposal = Proposal(
446
+ id=0,
447
+ machine_name=machine.name,
448
+ repo_name=repo,
449
+ issue_number=0,
450
+ issue_title=issue_title,
451
+ rationale="refine-board",
452
+ briefing=briefing,
453
+ model=resolved_model,
454
+ type="refinement",
455
+ required_gates=[],
456
+ )
457
+
458
+ response = dispatch_with_retry(
459
+ proposal, cfg,
460
+ max_retries=cfg.concurrency.max_retries,
461
+ backoff_base=cfg.concurrency.backoff_base,
462
+ )
463
+
464
+ assignment_id = response.get("id") or uuid.uuid4().hex[:12]
465
+
466
+ asg = Assignment(
467
+ machine_name=machine.name,
468
+ repo_name=repo,
469
+ issue_number=0,
470
+ issue_title=issue_title,
471
+ files_allowed=[],
472
+ files_forbidden=[],
473
+ briefing=briefing,
474
+ assignment_id=assignment_id,
475
+ status="running",
476
+ dispatched_at=time.time(),
477
+ type="refinement",
478
+ model=resolved_model,
479
+ )
480
+ record_dispatched_assignment(
481
+ assignment=asg,
482
+ repo_github=repo_cfg.github,
483
+ )
484
+
485
+ return assignment_id, machine.name