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,1243 @@
1
+ """Plan-mode follow-up commands: `pr`, `fix`, `review`, `approve-plan`,
2
+ `reject-plan`, `resume-stuck`, `split`. Extracted from coord/cli.py (#747)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import click
11
+ import httpx
12
+
13
+ from coord.config import Config
14
+ from coord.dispatch import DispatchRefused
15
+
16
+ from coord.commands._common import AGENT_PORT, _CONFIG_OPTION, _load_config
17
+
18
+
19
+ @click.command(help="Create sub-issues from a split proposal (e.g. coord split S1).")
20
+ @click.argument("ids")
21
+ @_CONFIG_OPTION
22
+ @click.option("--dry-run", is_flag=True, help="Show what would be created.")
23
+ def split(ids: str, config_path: Path, dry_run: bool) -> None:
24
+ from coord import github_ops
25
+ from coord.state import load_split_proposals, clear_split_proposals
26
+
27
+ cfg = _load_config(config_path)
28
+ splits = load_split_proposals()
29
+ if not splits:
30
+ click.echo("No pending split proposals. Run `coord plan` first.", err=True)
31
+ sys.exit(1)
32
+
33
+ try:
34
+ selected_ids = [int(x.strip().lstrip("Ss")) for x in ids.split(",")]
35
+ except ValueError:
36
+ click.echo("error: IDs must be comma-separated (e.g. S1,S2 or 1,2)", err=True)
37
+ sys.exit(2)
38
+
39
+ selected = [s for s in splits if s.id in selected_ids]
40
+ missing = set(selected_ids) - {s.id for s in selected}
41
+ if missing:
42
+ click.echo(f"error: unknown split proposal IDs: {missing}", err=True)
43
+ sys.exit(2)
44
+
45
+ for s in selected:
46
+ repo = cfg.repo(s.repo_name)
47
+ if repo is None:
48
+ click.echo(f"error: unknown repo {s.repo_name!r}", err=True)
49
+ continue
50
+
51
+ click.echo(f"\nSplitting #{s.issue_number}: {s.issue_title} into {len(s.chunks)} sub-issues:")
52
+
53
+ child_numbers: list[int] = []
54
+ for j, chunk in enumerate(s.chunks, 1):
55
+ title = f"{chunk.title} (sub-task {j}/{len(s.chunks)} of #{s.issue_number})"
56
+ body = (
57
+ f"## Sub-task of #{s.issue_number} — {s.issue_title}\n\n"
58
+ f"### Scope (chunk {j} of {len(s.chunks)}): {chunk.title}\n\n"
59
+ f"{chunk.scope}\n\n"
60
+ f"### Files likely touched\n\n"
61
+ + "\n".join(f"- `{f}`" for f in chunk.files_likely)
62
+ + f"\n\n### Context\n\n- Parent issue: #{s.issue_number}\n"
63
+ )
64
+
65
+ if dry_run:
66
+ click.echo(f" [{j}] would create: {title}")
67
+ continue
68
+
69
+ try:
70
+ result = github_ops.create_issue(
71
+ repo.github, title, body, labels=["sub-task"],
72
+ )
73
+ child_numbers.append(result["number"])
74
+ click.echo(f" [{j}] created #{result['number']}: {chunk.title}")
75
+ except RuntimeError as e:
76
+ click.echo(f" [{j}] failed to create: {e}", err=True)
77
+
78
+ if dry_run or not child_numbers:
79
+ continue
80
+
81
+ task_list = "\n".join(
82
+ f"- [ ] #{n}" for n in child_numbers
83
+ )
84
+ try:
85
+ github_ops.update_issue_body(
86
+ repo.github, s.issue_number,
87
+ f"Split into sub-tasks:\n\n{task_list}\n",
88
+ )
89
+ click.echo(f" Parent #{s.issue_number} updated with task list")
90
+ except RuntimeError as e:
91
+ click.echo(f" Failed to update parent: {e}", err=True)
92
+
93
+ if not dry_run:
94
+ clear_split_proposals()
95
+ click.echo("\nSplit proposals cleared. Run `coord plan` to assign the new sub-issues.")
96
+
97
+
98
+ def _dispatch_followup(
99
+ cfg: Config,
100
+ original: Assignment,
101
+ briefing: str,
102
+ *,
103
+ issue_suffix: str = "",
104
+ model: str | None = None,
105
+ type: str = "work",
106
+ files_likely: list[str] | None = None,
107
+ inherit_branch: bool = True,
108
+ ) -> str:
109
+ """Dispatch a follow-up assignment for an existing assignment. Returns assignment ID.
110
+
111
+ *model* overrides the model tier for the follow-up. When None, the
112
+ dispatcher falls back to ``cfg.models.default``.
113
+
114
+ *type* sets the assignment type (``"work"`` or ``"plan"``). Defaults to
115
+ ``"work"`` so existing callers are unaffected.
116
+
117
+ *files_likely* is the list of files the worker is expected to touch.
118
+ When None, an empty list is used (no file constraints).
119
+
120
+ *inherit_branch* controls whether the follow-up checks out the parent's
121
+ branch (``target_branch=original.branch``). True for follow-ups that
122
+ *continue* existing work on the same branch (``coord pr``, smoke-test
123
+ fix-up, continuation). Must be False when the parent is a read-only
124
+ PLAN assignment: a plan never pushes, its recorded branch is a
125
+ throwaway worktree name (sometimes a stale/wrong capture), and the
126
+ work it spawns must start a FRESH branch derived from the issue.
127
+ """
128
+ from coord.board_service import read_board, write_board
129
+ from coord.dispatch import dispatch, post_briefing, compute_do_not_touch
130
+ from coord.state import record_dispatched
131
+ from coord.models import Proposal
132
+
133
+ repo = cfg.repo(original.repo_name)
134
+ if repo is None:
135
+ raise ValueError(f"Unknown repo: {original.repo_name!r}")
136
+
137
+ proposal = Proposal(
138
+ id=0,
139
+ machine_name=original.machine_name,
140
+ repo_name=original.repo_name,
141
+ issue_number=original.issue_number,
142
+ issue_title=original.issue_title,
143
+ rationale=f"follow-up for assignment {original.assignment_id}",
144
+ briefing=briefing,
145
+ model=model if model else cfg.models.default,
146
+ type=type,
147
+ files_likely=files_likely if files_likely is not None else [],
148
+ # Pin the follow-up to the parent's branch when one exists AND the
149
+ # caller wants continuation. Without this, prefixed issue titles
150
+ # like `[fix-1] …` / `[conflict-fix] …` carried into
151
+ # _dispatch_followup (e.g. `coord pr` on a fix-up assignment)
152
+ # cause the agent to slugify the prefixed title and push to an
153
+ # orphan branch instead of the original PR's branch. But for a
154
+ # plan→work hand-off the parent is read-only and its branch is a
155
+ # throwaway (sometimes wrong) capture, so the work must branch
156
+ # fresh — callers pass inherit_branch=False there.
157
+ target_branch=(original.branch or None) if inherit_branch else None,
158
+ )
159
+
160
+ response = dispatch(proposal, cfg)
161
+ assignment_id = response.get("id", "pending")
162
+ record_dispatched(
163
+ assignment_id=assignment_id,
164
+ proposal=proposal,
165
+ repo_github=repo.github,
166
+ provider_name=response.get("_provider_name"),
167
+ )
168
+
169
+ # #906: use read_board() to build the peer-conflict in-flight list instead
170
+ # of load_dispatched() — the latter reads the local DB which is empty on a
171
+ # thin client. read_board() routes to the daemon's /board when configured,
172
+ # so we get the canonical active assignment list for do-not-touch detection.
173
+ # This also consolidates the board read we needed anyway for write_board().
174
+ board = read_board()
175
+ in_flight = [
176
+ {
177
+ "machine_name": a.machine_name,
178
+ "repo_name": a.repo_name,
179
+ "files_likely": a.files_allowed,
180
+ }
181
+ for a in board.active
182
+ if a.assignment_id != assignment_id # exclude just-dispatched
183
+ ]
184
+ do_not_touch = compute_do_not_touch(proposal, peers=[], in_flight=in_flight)
185
+ post_briefing(proposal, cfg, assignment_id=assignment_id, do_not_touch=do_not_touch)
186
+
187
+ write_board(board)
188
+
189
+ return assignment_id
190
+
191
+
192
+ def _load_plan_for_assignment(assignment, assignment_id: str) -> dict | None:
193
+ """Retrieve the plan dict for a plan-type assignment.
194
+
195
+ Tries (in order):
196
+ 1. The plan field cached on the assignment object.
197
+ 2. The plans table in the DB (populated by `coord notify`).
198
+ 3. Parsing the local log file directly (works when agent is local).
199
+
200
+ Returns the plan dict or None if not found.
201
+ """
202
+ from coord.state import COORD_DIR, load_plans
203
+
204
+ plan_dict = getattr(assignment, "plan", None)
205
+ if plan_dict is None:
206
+ plans = load_plans()
207
+ plan_dict = plans.get(assignment_id)
208
+ if plan_dict is None:
209
+ local_log = COORD_DIR / "logs" / f"{assignment_id}.log"
210
+ try:
211
+ from coord.plan_parser import parse_plan_from_log # noqa: PLC0415
212
+ worker_plan = parse_plan_from_log(local_log)
213
+ except Exception: # noqa: BLE001
214
+ worker_plan = None
215
+ if worker_plan is not None:
216
+ plan_dict = worker_plan.to_dict()
217
+ return plan_dict
218
+
219
+
220
+ def _plan_dict_to_text(plan_dict: dict) -> str:
221
+ """Format a WorkerPlan dict into a human-readable text block for briefings."""
222
+ from coord.plan_parser import WorkerPlan # noqa: PLC0415
223
+
224
+ plan = WorkerPlan.from_dict(plan_dict)
225
+ parts: list[str] = []
226
+ if plan.plan:
227
+ parts.append(f"Summary:\n{plan.plan}")
228
+ if plan.files_modify:
229
+ parts.append("Files to modify:\n" + "\n".join(f" - {f}" for f in plan.files_modify))
230
+ if plan.approach:
231
+ parts.append(f"Approach:\n{plan.approach}")
232
+ if plan.risks:
233
+ parts.append(f"Risks:\n{plan.risks}")
234
+ if plan.estimate:
235
+ parts.append(f"Estimate:\n{plan.estimate}")
236
+ # Smoke tests authored at planning time — the work worker re-emits
237
+ # these (refining if needed) in its own SMOKE_TESTS block before
238
+ # exit. Surfacing them in the briefing lets the worker copy them
239
+ # verbatim when the change matches the plan.
240
+ if plan.smoke_tests:
241
+ bullets = "\n".join(f" - {b}" for b in plan.smoke_tests)
242
+ parts.append(f"Smoke tests (from plan — re-emit in your SMOKE_TESTS block):\n{bullets}")
243
+ elif plan.smoke_tests == []:
244
+ parts.append(
245
+ "Smoke tests (from plan): (none — change is internal). "
246
+ "Emit `SMOKE_TESTS: (none — change is internal)` in your block."
247
+ )
248
+ # Fall back to raw_text when no structured sections were found.
249
+ if not parts:
250
+ return plan.raw_text or "(no plan text)"
251
+ return "\n\n".join(parts)
252
+
253
+
254
+ @click.command(help="Dispatch a worker to create a PR for a completed assignment.")
255
+ @click.argument("assignment_id")
256
+ @_CONFIG_OPTION
257
+ @click.option(
258
+ "--no-review",
259
+ is_flag=True,
260
+ default=False,
261
+ help="Skip auto-dispatching an adversarial review after the PR worker.",
262
+ )
263
+
264
+
265
+ def pr(assignment_id: str, config_path: Path, no_review: bool) -> None:
266
+ from coord.board_service import read_board, write_board
267
+
268
+ cfg = _load_config(config_path)
269
+ board = read_board()
270
+
271
+ assignment = board.find_by_id(assignment_id)
272
+ if assignment is None:
273
+ click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
274
+ sys.exit(1)
275
+
276
+ if assignment.status != "done":
277
+ click.echo(
278
+ f"error: assignment {assignment_id} is {assignment.status!r}, "
279
+ "can only create a PR for done assignments",
280
+ err=True,
281
+ )
282
+ sys.exit(1)
283
+
284
+ if not assignment.branch:
285
+ click.echo(
286
+ f"error: assignment {assignment_id} has no branch recorded. "
287
+ "The worker may not have pushed yet.",
288
+ err=True,
289
+ )
290
+ sys.exit(1)
291
+
292
+ repo = cfg.repo(assignment.repo_name)
293
+ if repo is None:
294
+ click.echo(f"error: unknown repo {assignment.repo_name!r}", err=True)
295
+ sys.exit(1)
296
+
297
+ default_branch = repo.default_branch
298
+ # #1077: "mock-author" (Gate A) assignments' issue_number is the
299
+ # milestone's tracking issue, not something this PR resolves — closing
300
+ # it on merge would wrongly flip the epic to "done" while its real
301
+ # sub-issues are untouched. Only "work"-type PRs get the closing
302
+ # keyword; everything else gets a non-closing reference.
303
+ from coord.models import CLOSES_ISSUE_TYPES, PR_HELPER_TYPE # noqa: PLC0415
304
+
305
+ closes_issue = assignment.type in CLOSES_ISSUE_TYPES
306
+
307
+ # #1314: the #1077 guard above assumed only mock-author/test-author are
308
+ # ever dispatched directly against a tracking issue's own number — but a
309
+ # type="work" assignment can land there too (e.g. a Gate-A contract
310
+ # correction with no properly-typed tool yet, see #1314). Closing an
311
+ # epic on merge because a single corrective PR touched its number would
312
+ # wrongly flip it to "done" while its real children sit untouched, so
313
+ # check the issue's own labels regardless of assignment type. Fail-open
314
+ # (network hiccup, unknown repo, ...) — keep the type-only verdict
315
+ # rather than block PR creation on a GitHub read.
316
+ if closes_issue:
317
+ try:
318
+ from coord import github_ops # noqa: PLC0415
319
+ from coord.milestone_order import TRACKING_ISSUE_LABEL # noqa: PLC0415
320
+
321
+ issue_data = github_ops.get_issue(repo.github, assignment.issue_number)
322
+ issue_labels = {
323
+ lbl.get("name", "") for lbl in (issue_data.get("labels") or [])
324
+ }
325
+ if TRACKING_ISSUE_LABEL in issue_labels:
326
+ closes_issue = False
327
+ except Exception: # noqa: BLE001 — fail open, see docstring above
328
+ pass
329
+
330
+ ref_keyword = (
331
+ f"Closes #{assignment.issue_number}"
332
+ if closes_issue
333
+ else f"Refs #{assignment.issue_number}"
334
+ )
335
+ briefing = (
336
+ f"You are on branch {assignment.branch}. The code is complete and tests pass.\n"
337
+ f"Create a PR from {assignment.branch} to {default_branch} for issue #{assignment.issue_number}.\n"
338
+ f"Title: {assignment.issue_title}\n\n"
339
+ f"Use gh pr create. Read the diff (git fetch origin && git diff origin/{default_branch}...HEAD) and write a clear\n"
340
+ f"summary of what changed. Reference the issue with \"{ref_keyword}\".\n"
341
+ f"Do NOT modify any code — only create the PR."
342
+ )
343
+
344
+ # #1142: only give the PR-opening helper `type="work"` when the original
345
+ # assignment's own type actually resolves `issue_number` (mirrors the
346
+ # Closes/Refs split above). Otherwise (test-author/mock-author/etc, whose
347
+ # issue_number is a milestone tracking issue) the helper gets a distinct
348
+ # `PR_HELPER_TYPE` so it can never be mistaken for that tracking issue's
349
+ # own merged work by `coord.stage_projection.merge_stage_status_for` (or
350
+ # any other heuristic keyed on `type == "work"` / `CLOSES_ISSUE_TYPES`).
351
+ followup_type = "work" if closes_issue else PR_HELPER_TYPE
352
+
353
+ try:
354
+ new_id = _dispatch_followup(cfg, assignment, briefing, type=followup_type)
355
+ except httpx.HTTPError as e:
356
+ click.echo(f"error: dispatch failed: {e}", err=True)
357
+ sys.exit(1)
358
+ except ValueError as e:
359
+ click.echo(f"error: {e}", err=True)
360
+ sys.exit(1)
361
+
362
+ click.echo(f"PR worker dispatched (assignment {new_id})")
363
+ click.echo(f" branch: {assignment.branch} → {default_branch}")
364
+ click.echo(f" issue: #{assignment.issue_number}: {assignment.issue_title}")
365
+
366
+ if not no_review and cfg.reviews.enabled:
367
+ from coord.review import dispatch_review
368
+
369
+ fresh_board = read_board()
370
+ review = dispatch_review(assignment, fresh_board, cfg)
371
+ if review is not None:
372
+ write_board(fresh_board)
373
+ click.echo(f"Review dispatched (assignment {review.assignment_id})")
374
+ click.echo(f" reviewer: {review.machine_name}")
375
+ else:
376
+ # #1627: report the specific guard dispatch_review hit rather
377
+ # than a generic guess.
378
+ reason = assignment.review_dispatch_reason or "reason not recorded"
379
+ click.echo(f" review not dispatched: {reason}")
380
+
381
+
382
+ @click.command(
383
+ help=(
384
+ "Deliberately dispatch a headless review for a done work assignment "
385
+ "(the #555 escape hatch — thin wrapper over dispatch_review)."
386
+ )
387
+ )
388
+ @click.argument("assignment_id")
389
+ @_CONFIG_OPTION
390
+ def review(assignment_id: str, config_path: Path) -> None:
391
+ """``coord review <work_assignment_id>``
392
+
393
+ The #555 guard in ``dispatch_pending_reviews`` deliberately never
394
+ auto-dispatches a headless ``claude -p`` review for an *interactive*
395
+ (``provider_name="claude-pty"``) work completion — that path already had
396
+ a human attending it. Its comment promises an escape hatch for a human
397
+ to deliberately request a headless review anyway; this command is it.
398
+
399
+ Pure dispatch — no PR worker is spawned. :func:`coord.review.dispatch_review`
400
+ already opens (or reuses) the PR itself, so unlike ``coord pr`` there is no
401
+ ``_dispatch_followup`` work session in between.
402
+ """
403
+ from coord.board_service import read_board, write_board
404
+ from coord.claim import has_active_followup
405
+ from coord.review import dispatch_review
406
+
407
+ cfg = _load_config(config_path)
408
+ board = read_board()
409
+
410
+ assignment = board.find_by_id(assignment_id)
411
+ if assignment is None:
412
+ click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
413
+ sys.exit(1)
414
+
415
+ if assignment.status != "done":
416
+ click.echo(
417
+ f"error: assignment {assignment_id} is {assignment.status!r}, not "
418
+ "'done' — nothing to review yet",
419
+ err=True,
420
+ )
421
+ sys.exit(1)
422
+
423
+ if not assignment.branch:
424
+ click.echo(
425
+ f"error: assignment {assignment_id} has no branch recorded. "
426
+ "The worker may not have pushed yet.",
427
+ err=True,
428
+ )
429
+ sys.exit(1)
430
+
431
+ if has_active_followup(
432
+ board, of_assignment_id=assignment_id, assignment_type="review"
433
+ ):
434
+ click.echo(
435
+ f"error: a review is already in flight for {assignment_id}",
436
+ err=True,
437
+ )
438
+ sys.exit(1)
439
+
440
+ if not cfg.reviews.enabled:
441
+ click.echo(
442
+ "error: reviews are disabled (reviews.enabled: false in config)",
443
+ err=True,
444
+ )
445
+ sys.exit(1)
446
+
447
+ review_assignment = dispatch_review(assignment, board, cfg)
448
+ if review_assignment is None:
449
+ # #1627: dispatch_review() records *why* it declined on the
450
+ # assignment itself (review_dispatch_reason) — print that verbatim
451
+ # instead of guessing at a cause. (It used to be a bare `return
452
+ # None` from any of 11 guards, most of which never logged anything,
453
+ # so this message used to send operators chasing a log entry that
454
+ # didn't exist — see #1627.)
455
+ reason = assignment.review_dispatch_reason or (
456
+ "no reason recorded — this is itself a bug in dispatch_review; "
457
+ "please report it"
458
+ )
459
+ click.echo(f"error: no review dispatched for {assignment_id} — {reason}", err=True)
460
+ sys.exit(1)
461
+
462
+ # Persist only on success: dispatch_review mutates the board in place
463
+ # (review_state, pr_url, the new review row).
464
+ write_board(board)
465
+ click.echo(
466
+ f"review dispatched: {review_assignment.assignment_id} on "
467
+ f"{review_assignment.machine_name}"
468
+ )
469
+ if assignment.pr_url:
470
+ click.echo(f" pr: {assignment.pr_url}")
471
+
472
+
473
+ def _ci_failure_story(cfg: Config, assignment) -> str | None:
474
+ """#1622 (part 3): a rendered summary of *assignment*'s FAILED CI checks.
475
+
476
+ The third legitimate fix trigger, alongside a request-changes review and a
477
+ failed local test gate. CI reports failures the local Test stage cannot
478
+ see — vimcode #613 was a sibling path-dep that only breaks in a clean
479
+ checkout — and the fix has to land on the *existing* branch, because the
480
+ thing being fixed is what gates the merge.
481
+
482
+ Read-only and fail-quiet: returns ``None`` when CI is not configured
483
+ (``ci_store.type: none`` ⇒ :class:`coord.ci_store.NoOpCi`, whose
484
+ ``is_available`` is False), when the branch has no PR to read checks for,
485
+ when the read raises, or when every completed check is affirmatively
486
+ passing. ``None`` therefore means "no CI evidence of a failure", never
487
+ "CI is green" — the caller must not treat it as a verdict.
488
+ """
489
+ from coord.ci_store import build_ci_store, failed_checks, summarize
490
+ from coord.drive import _extract_pr_number
491
+
492
+ repo = cfg.repo(assignment.repo_name)
493
+ if repo is None or not repo.github:
494
+ return None
495
+
496
+ pr_number = _extract_pr_number(assignment.pr_url or "")
497
+ if pr_number is None:
498
+ return None
499
+
500
+ store = build_ci_store(cfg.ci_store.type)
501
+ if not store.is_available:
502
+ return None
503
+
504
+ try:
505
+ checks = store.list_checks_for_pr(repo.github, pr_number)
506
+ except Exception as exc: # noqa: BLE001 — CI read is advisory, never fatal
507
+ click.echo(f"warning: could not read CI checks for PR #{pr_number}: {exc}", err=True)
508
+ return None
509
+
510
+ failed = failed_checks(checks)
511
+ if not failed:
512
+ return None
513
+
514
+ lines = [
515
+ f"CI on PR #{pr_number} is RED ({summarize(checks)}).",
516
+ "",
517
+ "Failed checks:",
518
+ ]
519
+ for c in failed:
520
+ lines.append(f"- {c.name} — conclusion={c.conclusion!r}{(' — ' + c.url) if c.url else ''}")
521
+ lines += [
522
+ "",
523
+ "These failures were NOT visible to the local Test stage. Read the run "
524
+ "logs (`gh run view <id> --log-failed`, or open the URLs above), "
525
+ "reproduce locally where you can, and fix the root cause on THIS "
526
+ "branch — a new branch would not carry the change CI is gating on.",
527
+ ]
528
+ return "\n".join(lines)
529
+
530
+
531
+ def _fix_from_review(
532
+ cfg: Config,
533
+ board,
534
+ review,
535
+ *,
536
+ guidance: str,
537
+ force: bool,
538
+ ) -> None:
539
+ """#1622: dispatch a HEADLESS fix round for a request-changes review.
540
+
541
+ This is a CLI **door** onto the review→fix auto-loop, not a second
542
+ implementation of same-branch dispatch. It hands the review row straight
543
+ to :func:`coord.auto_loop.process_review_completion`, the same function the
544
+ ``coord notify`` transition path calls, so the fix worker is produced by the
545
+ one and only :func:`coord.auto_loop._dispatch_fix` — which pins
546
+ ``target_branch`` to the reviewed work's branch and bumps
547
+ ``review_iteration``. Every guard that path applies applies here unchanged:
548
+ ``pipeline.auto_loop``, the #476/#1456 approve-with-nits gate, the #522
549
+ terminal-work guard, and the ``max_review_iterations`` cap.
550
+
551
+ Two things are added on top, both *before* the shared call:
552
+
553
+ - the #555 interactive exclusion, checked against the reviewed WORK row so
554
+ an interactive completion is never followed by a headless fix; and
555
+ - resolution of the review's log path / machine host, which the transition
556
+ path gets handed by ``coord notify`` and a CLI invocation does not.
557
+
558
+ Never returns — always exits via :func:`sys.exit`.
559
+ """
560
+ from coord import auto_loop
561
+ from coord.board_service import write_board
562
+ from coord.state import COORD_DIR
563
+
564
+ if guidance:
565
+ click.echo(
566
+ "warning: --guidance is ignored for a review id — the reviewer's "
567
+ "findings ARE the fix briefing. To brief a fix in your own words "
568
+ f"use `coord assign <machine> <repo> <issue> --fix-of "
569
+ f"{review.assignment_id} --interactive --briefing ...`.",
570
+ err=True,
571
+ )
572
+
573
+ work = None
574
+ if review.review_of_assignment_id:
575
+ work = board.find_by_id(review.review_of_assignment_id)
576
+ if work is None:
577
+ click.echo(
578
+ f"error: review {review.assignment_id} has no linked work assignment "
579
+ f"on the board (review_of_assignment_id="
580
+ f"{review.review_of_assignment_id!r}) — nothing to fix",
581
+ err=True,
582
+ )
583
+ sys.exit(1)
584
+
585
+ # #555: an INTERACTIVE work completion must never be silently followed by a
586
+ # headless fix. The human at the tmux pane owns that branch and may still
587
+ # be mid-edit; a `claude -p` worker pushing over them is the incident that
588
+ # exclusion exists to prevent. The auto-loop enforces this on the
589
+ # re-review side (`run_for_fix_transition`); a CLI door onto the *dispatch*
590
+ # side needs its own check, because `process_review_completion` never sees
591
+ # the provider. `--force` is the deliberate override for the case the
592
+ # exclusion cannot detect: the session is genuinely gone.
593
+ if (work.provider_name or "") == "claude-pty" and not force:
594
+ click.echo(
595
+ f"error: work {work.assignment_id} ran INTERACTIVELY "
596
+ "(provider=claude-pty); refusing to follow it with a headless fix "
597
+ "(#555). Continue in that session, or run `coord assign ... "
598
+ f"--fix-of {review.assignment_id} --interactive`, or pass --force "
599
+ "if the interactive session is really gone.",
600
+ err=True,
601
+ )
602
+ sys.exit(1)
603
+
604
+ # The transition path is handed these by `coord notify`; reconstruct them
605
+ # so the findings loader keeps all four of its sources (DB cache → local
606
+ # log → agent HTTP → GitHub message bus) rather than only the first and last.
607
+ _log = COORD_DIR / "logs" / f"{review.assignment_id}.log"
608
+ log_path = str(_log) if _log.exists() else None
609
+ machine_host = None
610
+ _machine = next(
611
+ (m for m in cfg.machines if m.name == review.machine_name), None
612
+ )
613
+ if _machine is not None and _machine.host:
614
+ machine_host = _machine.host
615
+
616
+ before = {a.assignment_id for a in board.active}
617
+ actions = auto_loop.process_review_completion(
618
+ review, board, cfg, log_path=log_path, machine_host=machine_host,
619
+ )
620
+
621
+ # `process_review_completion` mutates the board and leaves persistence to
622
+ # its caller — same contract, same kind list, as the transition path.
623
+ if any(a.kind in auto_loop.PERSIST_ACTION_KINDS for a in actions):
624
+ write_board(board)
625
+
626
+ kinds = {a.kind for a in actions}
627
+ detail = "; ".join(a.detail for a in actions if a.detail)
628
+
629
+ if "fix_dispatched" in kinds:
630
+ max_iter = cfg.pipeline.max_review_iterations
631
+ click.echo(f"Fix worker dispatched ({detail})")
632
+ for row in board.active:
633
+ if row.assignment_id in before:
634
+ continue
635
+ click.echo(f" assignment: {row.assignment_id}")
636
+ click.echo(f" branch: {row.branch}")
637
+ click.echo(f" review iteration: {row.review_iteration}/{max_iter}")
638
+ click.echo(f" issue: #{work.issue_number}: {work.issue_title}")
639
+ if work.pr_url:
640
+ click.echo(f" pr: {work.pr_url}")
641
+ return
642
+
643
+ # Everything else is a refusal. Each one is a guard doing its job, so say
644
+ # which guard and exit non-zero — a headless drive keys off the exit code.
645
+ _why = {
646
+ "disabled": (
647
+ "pipeline.auto_loop is disabled in coordinator.yml — the review→fix "
648
+ "path this command opens is switched off"
649
+ ),
650
+ "no_findings": (
651
+ "no structured review findings could be resolved (DB cache, local "
652
+ "log, agent HTTP and the GitHub findings comment were all empty)"
653
+ ),
654
+ "approved": "review verdict is approve — nothing to fix",
655
+ "approved_with_nits": (
656
+ "review raised no blocking findings, only advisory ones — the #476 "
657
+ "gate advanced the pipeline instead of dispatching a fix"
658
+ ),
659
+ "max_iterations": (
660
+ "pipeline.max_review_iterations reached — raise it in "
661
+ "coordinator.yml or take the branch over by hand"
662
+ ),
663
+ "terminal_skip": (
664
+ "the reviewed work is already merged/closed on GitHub (#522)"
665
+ ),
666
+ "no_work_found": "fix dispatch failed",
667
+ }
668
+ reason = next(
669
+ (_why[a.kind] for a in actions if a.kind in _why),
670
+ f"auto-loop returned {sorted(kinds) or 'nothing'}",
671
+ )
672
+ click.echo(f"error: no fix dispatched for review {review.assignment_id}: {reason}", err=True)
673
+ if detail:
674
+ click.echo(f" {detail}", err=True)
675
+ sys.exit(1)
676
+
677
+
678
+ @click.command(
679
+ help=(
680
+ "Dispatch a headless same-branch fix worker.\n\n"
681
+ "ASSIGNMENT_ID is either a WORK assignment whose test gate FAILED (or "
682
+ "whose PR has red CI), or a REVIEW assignment whose verdict was "
683
+ "request-changes. Either way the fix lands on the ORIGINAL branch and "
684
+ "updates the ORIGINAL PR — no new issue-N-* branch, no orphan PR."
685
+ )
686
+ )
687
+ @click.argument("assignment_id")
688
+ @_CONFIG_OPTION
689
+ @click.option("--guidance", default="", help="Additional guidance for the fix-up worker.")
690
+ @click.option(
691
+ "--force",
692
+ is_flag=True,
693
+ help=(
694
+ "Override the #555 interactive-work exclusion when fixing a review of "
695
+ "work that ran under claude-pty, or (#2051) dispatch a WORK-row fix "
696
+ "when neither the test verdict nor a live CI read shows a failure — "
697
+ "for when the caller knows the PR is red but the check missed it "
698
+ "(ci_store not configured, a transient read error). Does NOT override "
699
+ "max_review_iterations or the #522 terminal-work guard."
700
+ ),
701
+ )
702
+ def fix(assignment_id: str, config_path: Path, guidance: str, force: bool) -> None:
703
+ from coord.board_service import read_board
704
+ from coord.state import COORD_DIR
705
+
706
+ cfg = _load_config(config_path)
707
+ board = read_board()
708
+
709
+ assignment = board.find_by_id(assignment_id)
710
+ if assignment is None:
711
+ click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
712
+ sys.exit(1)
713
+
714
+ # #1622: a REVIEW id is the request-changes door. It routes through
715
+ # `coord.auto_loop`, which owns same-branch fix dispatch — this command
716
+ # deliberately does not grow a second implementation of it.
717
+ if assignment.type == "review":
718
+ _fix_from_review(cfg, board, assignment, guidance=guidance, force=force)
719
+ return
720
+
721
+ # #1384: gate on the canonical `test_state` with the legacy `smoke_test`
722
+ # mirror as fallback, so the two fields can never drift apart again. The
723
+ # writer (`state._record_test_verdict_local`) now derives the mirror, but
724
+ # rows recorded before that fix — a headless smoke failure via #1021's
725
+ # `coord/notify.py` propagation — carry `test_state='failed'` with
726
+ # `smoke_test=NULL` and must still be fixable.
727
+ test_failed = assignment.test_state == "failed" or assignment.smoke_test == "fail"
728
+
729
+ # #1622 (part 3): red CI is the third trigger. Only consulted when the
730
+ # local test gate has NOT already failed, so the cheap in-DB path stays
731
+ # zero-I/O and the existing failure story keeps priority.
732
+ ci_story = None if test_failed else _ci_failure_story(cfg, assignment)
733
+
734
+ # #2051: none of the three doors (failed test verdict, red CI, or the
735
+ # review-id door handled above) is open. `_ci_failure_story` is
736
+ # read-only and fail-quiet (see its docstring) — it returns `None` for
737
+ # "no evidence of a failure", never "CI is green" — so a caller who
738
+ # KNOWS the PR is red (ci_store not configured for this repo, a
739
+ # transient GitHub API error, a check that hasn't reported yet) would
740
+ # otherwise be stranded with no headless door onto the ORIGINAL branch.
741
+ # `--force` is that caller's release valve; it is NOT a way to skip the
742
+ # #555 / #522 / max_review_iterations guards, which sit elsewhere.
743
+ forced_without_evidence = False
744
+ if not test_failed and ci_story is None:
745
+ if not force:
746
+ click.echo(
747
+ f"error: assignment {assignment_id} test_state is "
748
+ f"{assignment.test_state!r} / smoke_test is "
749
+ f"{assignment.smoke_test!r}, expected a failed test verdict "
750
+ "(or red CI on its PR, or a request-changes review id). If "
751
+ "the PR is actually red and this check missed it, re-run "
752
+ "with --force.",
753
+ err=True,
754
+ )
755
+ sys.exit(1)
756
+ if not guidance:
757
+ # There's no failed verdict, CI read, or test-output file to
758
+ # brief the fix worker with — `--force` alone would dispatch it
759
+ # blind. Require the caller to say what's actually broken.
760
+ click.echo(
761
+ f"error: --force on assignment {assignment_id} also needs "
762
+ "--guidance — there's no failed test verdict or CI read to "
763
+ "brief the fix worker with, so say what's actually broken.",
764
+ err=True,
765
+ )
766
+ sys.exit(1)
767
+ forced_without_evidence = True
768
+
769
+ repo = cfg.repo(assignment.repo_name)
770
+ if repo is None:
771
+ click.echo(f"error: unknown repo {assignment.repo_name!r}", err=True)
772
+ sys.exit(1)
773
+
774
+ default_branch = repo.default_branch
775
+
776
+ # Load stored test output if available
777
+ test_output = ""
778
+ test_output_file = COORD_DIR / "test_output" / f"{assignment_id}.txt"
779
+ if ci_story is not None:
780
+ test_output = ci_story
781
+ elif test_output_file.exists():
782
+ test_output = test_output_file.read_text()
783
+ elif assignment.smoke_test_reason or assignment.test_reason:
784
+ # #1337: the board wire carries a bounded PREVIEW of the reason text;
785
+ # the briefing quotes it verbatim — prefer the full text via the
786
+ # detail loader (falls back to the board-carried value).
787
+ # #1384: fall back to `test_reason` too — a headless-smoke failure
788
+ # recorded before the mirror-derivation fix has no smoke_test_reason.
789
+ from coord.state import load_assignment_test_reason as _load_tr # noqa: PLC0415
790
+
791
+ test_output = (
792
+ _load_tr(assignment_id)
793
+ or assignment.smoke_test_reason
794
+ or assignment.test_reason
795
+ or ""
796
+ )
797
+
798
+ guidance_text = guidance or "Fix the failing tests and push."
799
+ if forced_without_evidence:
800
+ _what = (
801
+ "a reported failure (--force: neither a failed test verdict nor "
802
+ "an automated CI read found one — the caller attests the PR is "
803
+ "red)"
804
+ )
805
+ _failure_heading = "Reported failure (--force, unverified)"
806
+ else:
807
+ _what = "red CI" if ci_story is not None else "a failed smoke test"
808
+ _failure_heading = "CI failure" if ci_story is not None else "Test failure"
809
+
810
+ briefing = (
811
+ f"You are fixing {_what} for issue #{assignment.issue_number}: {assignment.issue_title}\n\n"
812
+ f"The previous worker created branch {assignment.branch}. You are already on that branch.\n"
813
+ f"Do NOT start over — work from the existing code.\n\n"
814
+ f"## What was done\n"
815
+ f"The previous worker's changes are already committed on this branch.\n"
816
+ f"Run `git fetch origin && git log --oneline origin/{default_branch}..HEAD` to see what was done.\n"
817
+ f"Run `git diff origin/{default_branch}...HEAD` to see the full diff.\n\n"
818
+ f"## {_failure_heading}\n"
819
+ f"{test_output}\n\n"
820
+ f"## Guidance\n"
821
+ f"{guidance_text}\n\n"
822
+ f"## Rules\n"
823
+ f"- Do NOT start over or rewrite from scratch\n"
824
+ f"- Fix the specific test failures\n"
825
+ f"- Commit your fixes and push with git push origin HEAD"
826
+ )
827
+
828
+ # Determine escalated model for the fix-up.
829
+ original_model = assignment.model or cfg.models.default
830
+ escalated = cfg.models.next_model(original_model)
831
+ if escalated != original_model:
832
+ click.echo(f" escalating model: {original_model} → {escalated}")
833
+
834
+ try:
835
+ new_id = _dispatch_followup(cfg, assignment, briefing, model=escalated)
836
+ except httpx.HTTPError as e:
837
+ click.echo(f"error: dispatch failed: {e}", err=True)
838
+ sys.exit(1)
839
+ except DispatchRefused as e:
840
+ # #1844: this arm of `coord fix` (a failed test / red CI, reached
841
+ # from a WORK assignment id — see `coord/drive.py`'s
842
+ # `command=("fix", state.work_aid)`) is a RUN action `coord drive`
843
+ # dispatches directly, and `_dispatch_followup` → `dispatch()` raises
844
+ # THIS (a `ValueError` subclass) specifically for a deterministic
845
+ # pre-dispatch guard refusal — not worth a retry. (The OTHER `coord
846
+ # fix` arm — a request-changes review id — routes through
847
+ # `_fix_from_review` → `coord.auto_loop._dispatch_fix`, which POSTs
848
+ # directly and never runs these guards at all; this branch does not
849
+ # cover it.) Same distinguishable exit code as `_dispatch_headless`'s
850
+ # equivalent branch (coord/commands/dispatch_workers.py), so `coord
851
+ # drive`'s subprocess call can tell this apart from a crash.
852
+ from coord.drive import EXIT_DISPATCH_REFUSED # noqa: PLC0415
853
+
854
+ click.echo(f"error: {e}", err=True)
855
+ sys.exit(EXIT_DISPATCH_REFUSED)
856
+ except ValueError as e:
857
+ click.echo(f"error: {e}", err=True)
858
+ sys.exit(1)
859
+
860
+ click.echo(f"Fix-up worker dispatched (assignment {new_id})")
861
+ click.echo(f" branch: {assignment.branch}")
862
+ click.echo(f" issue: #{assignment.issue_number}: {assignment.issue_title}")
863
+ if test_output:
864
+ _label = "CI failure summary" if ci_story is not None else "test output"
865
+ click.echo(f" {_label} included in briefing ({len(test_output)} chars)")
866
+
867
+
868
+ @click.command(
869
+ "approve-plan",
870
+ help=(
871
+ "Approve a completed plan assignment and dispatch a work assignment "
872
+ "to implement it."
873
+ ),
874
+ )
875
+
876
+
877
+ @click.argument("assignment_id")
878
+ @_CONFIG_OPTION
879
+ def approve_plan(assignment_id: str, config_path: Path) -> None:
880
+ from coord.board_service import read_board
881
+
882
+ cfg = _load_config(config_path)
883
+ board = read_board()
884
+
885
+ assignment = board.find_by_id(assignment_id)
886
+ if assignment is None:
887
+ click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
888
+ sys.exit(1)
889
+
890
+ if assignment.type != "plan":
891
+ click.echo(
892
+ f"error: assignment {assignment_id} is type {assignment.type!r}, not 'plan'. "
893
+ "Only plan assignments can be approved with approve-plan.",
894
+ err=True,
895
+ )
896
+ sys.exit(1)
897
+
898
+ if assignment.status != "done":
899
+ click.echo(
900
+ f"error: assignment {assignment_id} is {assignment.status!r}, not 'done'. "
901
+ "The plan worker must finish before you can approve it.",
902
+ err=True,
903
+ )
904
+ sys.exit(1)
905
+
906
+ plan_dict = _load_plan_for_assignment(assignment, assignment_id)
907
+ if plan_dict is None:
908
+ click.echo(
909
+ f"error: no plan data found for assignment {assignment_id}.\n"
910
+ "Possible reasons: the log is on a remote machine, or the worker "
911
+ "did not output plan sections.\n"
912
+ "Run 'coord notify' after the worker finishes to parse and cache the plan.",
913
+ err=True,
914
+ )
915
+ sys.exit(1)
916
+
917
+ plan_text = _plan_dict_to_text(plan_dict)
918
+
919
+ # Build the enhanced briefing for the work assignment.
920
+ original_briefing = (assignment.briefing or "").strip()
921
+ separator = "\n\n" if original_briefing else ""
922
+ enhanced_briefing = (
923
+ original_briefing
924
+ + separator
925
+ + "Your plan was reviewed and approved. Implement exactly as described:\n\n"
926
+ + plan_text
927
+ ).strip()
928
+
929
+ # Use files_modify from the plan as the allowed-files hint for the worker.
930
+ from coord.plan_parser import WorkerPlan # noqa: PLC0415
931
+ plan_obj = WorkerPlan.from_dict(plan_dict)
932
+ files_likely = plan_obj.files_modify or assignment.files_allowed or []
933
+
934
+ # #1430: the plan's ESTIMATE — informed by actually reading the code —
935
+ # is a better signal than the label chosen at issue-creation time, so it
936
+ # overrides the label-derived model for the work assignment. Falls back
937
+ # to the label when there's no usable ESTIMATE, and to models.default
938
+ # when neither resolves (mirrors dispatch()'s own precedence).
939
+ label_model: str | None = None
940
+ repo = cfg.repo(assignment.repo_name)
941
+ if repo is not None:
942
+ try:
943
+ from coord import github_ops # noqa: PLC0415
944
+ issue_data = github_ops.get_issue(repo.github, assignment.issue_number)
945
+ issue_labels = [
946
+ lbl.get("name", "") for lbl in (issue_data.get("labels") or [])
947
+ ]
948
+ label_model = cfg.models.model_for_labels(issue_labels)
949
+ except RuntimeError:
950
+ pass # best-effort — fall through to estimate/default below
951
+ estimate_model = cfg.models.model_for_estimate(plan_obj.estimate)
952
+ if estimate_model:
953
+ work_model = estimate_model
954
+ click.echo(
955
+ f" model: {work_model} (from plan ESTIMATE={plan_obj.estimate!r}"
956
+ + (f", overriding label-derived {label_model!r}" if label_model else "")
957
+ + ")"
958
+ )
959
+ elif label_model:
960
+ work_model = label_model
961
+ click.echo(f" model: {work_model} (from issue label)")
962
+ else:
963
+ work_model = None
964
+ click.echo(f" model: {cfg.models.default} (default)")
965
+
966
+ click.echo(
967
+ f"Approving plan {assignment_id}: "
968
+ f"{assignment.repo_name} #{assignment.issue_number} — {assignment.issue_title}"
969
+ )
970
+ click.echo(f" Dispatching work assignment to {assignment.machine_name}...")
971
+
972
+ try:
973
+ new_id = _dispatch_followup(
974
+ cfg,
975
+ assignment,
976
+ enhanced_briefing,
977
+ model=work_model,
978
+ type="work",
979
+ files_likely=files_likely,
980
+ # The plan is read-only; its recorded branch is a throwaway
981
+ # worktree name (and can be a stale/wrong capture). Work must
982
+ # branch fresh from the issue, not inherit the plan's branch.
983
+ inherit_branch=False,
984
+ )
985
+ except httpx.HTTPError as e:
986
+ click.echo(f"error: dispatch failed: {e}", err=True)
987
+ sys.exit(1)
988
+ except DispatchRefused as e:
989
+ # #1844: same reasoning as `_dispatch_headless`'s equivalent branch
990
+ # (coord/commands/dispatch_workers.py) — `dispatch()` raises THIS (a
991
+ # `ValueError` subclass) specifically for a deterministic
992
+ # pre-dispatch guard refusal. `coord approve-plan` is the other RUN
993
+ # action `coord drive`'s work-stage dispatch can hit this on (the
994
+ # #1453 plan → work hand-off), so it needs the same distinguishable
995
+ # exit code.
996
+ from coord.drive import EXIT_DISPATCH_REFUSED # noqa: PLC0415
997
+
998
+ click.echo(f"error: {e}", err=True)
999
+ sys.exit(EXIT_DISPATCH_REFUSED)
1000
+ except ValueError as e:
1001
+ click.echo(f"error: {e}", err=True)
1002
+ sys.exit(1)
1003
+
1004
+ # Persist plan-stage SMOKE_TESTS onto the new work assignment so the
1005
+ # TUI surfaces them immediately — and so they survive even if the
1006
+ # work worker exits without re-emitting its own block. The work
1007
+ # worker's later SMOKE_TESTS (captured by notify._capture_smoke_tests)
1008
+ # overrides this when present.
1009
+ if plan_obj.smoke_tests is not None:
1010
+ from coord.state import update_assignment_smoke_tests # noqa: PLC0415
1011
+ update_assignment_smoke_tests(new_id, plan_obj.smoke_tests)
1012
+
1013
+ click.echo(f" Work assignment dispatched (assignment {new_id})")
1014
+ click.echo(f" repo: {assignment.repo_name} issue: #{assignment.issue_number}")
1015
+ click.echo(f" Run: coord log {new_id} to follow progress")
1016
+
1017
+
1018
+ @click.command(
1019
+ "reject-plan",
1020
+ help=(
1021
+ "Reject a completed plan assignment and re-dispatch for revision "
1022
+ "with additional guidance."
1023
+ ),
1024
+ )
1025
+
1026
+
1027
+ @click.argument("assignment_id")
1028
+ @_CONFIG_OPTION
1029
+ @click.option(
1030
+ "--guidance",
1031
+ required=True,
1032
+ help="Guidance text explaining what to revise in the plan.",
1033
+ )
1034
+
1035
+
1036
+ def reject_plan(assignment_id: str, config_path: Path, guidance: str) -> None:
1037
+ from coord.board_service import read_board
1038
+
1039
+ cfg = _load_config(config_path)
1040
+ board = read_board()
1041
+
1042
+ assignment = board.find_by_id(assignment_id)
1043
+ if assignment is None:
1044
+ click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
1045
+ sys.exit(1)
1046
+
1047
+ if assignment.type != "plan":
1048
+ click.echo(
1049
+ f"error: assignment {assignment_id} is type {assignment.type!r}, not 'plan'. "
1050
+ "Only plan assignments can be rejected with reject-plan.",
1051
+ err=True,
1052
+ )
1053
+ sys.exit(1)
1054
+
1055
+ if assignment.status != "done":
1056
+ click.echo(
1057
+ f"error: assignment {assignment_id} is {assignment.status!r}, not 'done'. "
1058
+ "The plan worker must finish before you can reject it.",
1059
+ err=True,
1060
+ )
1061
+ sys.exit(1)
1062
+
1063
+ plan_dict = _load_plan_for_assignment(assignment, assignment_id)
1064
+ if plan_dict is None:
1065
+ click.echo(
1066
+ f"error: no plan data found for assignment {assignment_id}.\n"
1067
+ "Possible reasons: the log is on a remote machine, or the worker "
1068
+ "did not output plan sections.\n"
1069
+ "Run 'coord notify' after the worker finishes to parse and cache the plan.",
1070
+ err=True,
1071
+ )
1072
+ sys.exit(1)
1073
+
1074
+ plan_text = _plan_dict_to_text(plan_dict)
1075
+
1076
+ # Build the enhanced briefing for the revised plan assignment.
1077
+ original_briefing = (assignment.briefing or "").strip()
1078
+ separator = "\n\n" if original_briefing else ""
1079
+ enhanced_briefing = (
1080
+ original_briefing
1081
+ + separator
1082
+ + "Previous plan (rejected):\n\n"
1083
+ + plan_text
1084
+ + "\n\nGuidance:\n\n"
1085
+ + guidance.strip()
1086
+ ).strip()
1087
+
1088
+ click.echo(
1089
+ f"Rejecting plan {assignment_id}: "
1090
+ f"{assignment.repo_name} #{assignment.issue_number} — {assignment.issue_title}"
1091
+ )
1092
+ click.echo(f" Re-dispatching revised plan to {assignment.machine_name}...")
1093
+
1094
+ try:
1095
+ new_id = _dispatch_followup(
1096
+ cfg,
1097
+ assignment,
1098
+ enhanced_briefing,
1099
+ type="plan",
1100
+ files_likely=list(assignment.files_allowed),
1101
+ # Revised plan is read-only too — don't inherit the prior
1102
+ # plan's throwaway branch.
1103
+ inherit_branch=False,
1104
+ )
1105
+ except httpx.HTTPError as e:
1106
+ click.echo(f"error: dispatch failed: {e}", err=True)
1107
+ sys.exit(1)
1108
+ except ValueError as e:
1109
+ click.echo(f"error: {e}", err=True)
1110
+ sys.exit(1)
1111
+
1112
+ click.echo(f" Revised plan assignment dispatched (assignment {new_id})")
1113
+ click.echo(f" repo: {assignment.repo_name} issue: #{assignment.issue_number}")
1114
+ click.echo(f" Run: coord log {new_id} to follow progress")
1115
+
1116
+
1117
+ @click.command(
1118
+ "resume-stuck",
1119
+ help="Stop a stuck worker and dispatch a continuation with guidance.",
1120
+ )
1121
+
1122
+
1123
+ @click.argument("assignment_id")
1124
+ @_CONFIG_OPTION
1125
+ @click.option("--guidance", required=True, help="Guidance for the continuation worker.")
1126
+ def resume_stuck(assignment_id: str, config_path: Path, guidance: str) -> None:
1127
+ from coord.board_service import read_board
1128
+
1129
+ cfg = _load_config(config_path)
1130
+ board = read_board()
1131
+
1132
+ assignment = board.find_by_id(assignment_id)
1133
+ if assignment is None:
1134
+ click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
1135
+ sys.exit(1)
1136
+
1137
+ if assignment.status != "running":
1138
+ click.echo(
1139
+ f"error: assignment {assignment_id} is {assignment.status!r}, "
1140
+ "can only resume-stuck a running assignment",
1141
+ err=True,
1142
+ )
1143
+ sys.exit(1)
1144
+
1145
+ # Find the machine this assignment is running on
1146
+ machine = next(
1147
+ (m for m in cfg.machines if m.name == assignment.machine_name), None
1148
+ )
1149
+ if machine is None:
1150
+ click.echo(
1151
+ f"error: machine {assignment.machine_name!r} not in config", err=True
1152
+ )
1153
+ sys.exit(1)
1154
+
1155
+ # Stop the current worker. #1567 changed `coord stop`'s default to NOT
1156
+ # push a worker's uncommitted WIP anywhere — but resume-stuck is not a
1157
+ # `coord stop`: it immediately dispatches a continuation onto this same
1158
+ # branch (below) and that continuation's fresh worktree is built from
1159
+ # `origin/<branch>` (see AgentServer._setup_worktree's continuation
1160
+ # path), so any uncommitted work MUST reach the branch on the remote or
1161
+ # the continuation silently starts over from before it. push_mode=branch
1162
+ # keeps the pre-#1567 behaviour here on purpose.
1163
+ try:
1164
+ resp = httpx.post(
1165
+ f"http://{machine.host}:{AGENT_PORT}/cancel/{assignment_id}",
1166
+ params={"push_mode": "branch"},
1167
+ timeout=10,
1168
+ )
1169
+ resp.raise_for_status()
1170
+ click.echo(f"Cancelled stuck worker on {machine.name}")
1171
+ except (httpx.HTTPError, httpx.TimeoutException) as e:
1172
+ click.echo(
1173
+ f"warning: could not cancel worker on {machine.name}: {e} "
1174
+ "(may have already stopped)",
1175
+ err=True,
1176
+ )
1177
+
1178
+ # Brief pause for cancellation to take effect
1179
+ time.sleep(2)
1180
+
1181
+ # Retrieve the stuck message from the agent's progress data
1182
+ stuck_message = ""
1183
+ try:
1184
+ status_resp = httpx.get(
1185
+ f"http://{machine.host}:{AGENT_PORT}/status", timeout=5
1186
+ )
1187
+ if status_resp.status_code == 200:
1188
+ status_data = status_resp.json()
1189
+ # Check active and completed for progress info
1190
+ for entry in status_data.get("active", []) + status_data.get("completed", []):
1191
+ if entry.get("id") == assignment_id:
1192
+ progress = entry.get("progress", {})
1193
+ if progress and progress.get("stuck"):
1194
+ stuck_message = progress["stuck"]
1195
+ break
1196
+ except Exception: # noqa: BLE001
1197
+ pass
1198
+
1199
+ repo = cfg.repo(assignment.repo_name)
1200
+ if repo is None:
1201
+ click.echo(f"error: unknown repo {assignment.repo_name!r}", err=True)
1202
+ sys.exit(1)
1203
+
1204
+ default_branch = repo.default_branch
1205
+
1206
+ stuck_section = stuck_message if stuck_message else "(no stuck message captured)"
1207
+
1208
+ briefing = (
1209
+ f"You are continuing work on issue #{assignment.issue_number}: {assignment.issue_title}\n\n"
1210
+ f"The previous worker got stuck on branch {assignment.branch or 'unknown'}. "
1211
+ f"You are already on that branch.\n"
1212
+ f"Do NOT start over — continue from where they left off.\n\n"
1213
+ f"## What was done\n"
1214
+ f"Run `git fetch origin && git log --oneline origin/{default_branch}..HEAD` to see previous work.\n"
1215
+ f"Run `git diff origin/{default_branch}...HEAD` to see the full diff.\n\n"
1216
+ f"## What the previous worker was stuck on\n"
1217
+ f"{stuck_section}\n\n"
1218
+ f"## Guidance\n"
1219
+ f"{guidance}\n\n"
1220
+ f"## Rules\n"
1221
+ f"- Continue from the existing branch, do not start over\n"
1222
+ f"- Commit your work and push with git push origin HEAD"
1223
+ )
1224
+
1225
+ # Determine escalated model for the continuation worker.
1226
+ original_model = assignment.model or cfg.models.default
1227
+ escalated = cfg.models.next_model(original_model)
1228
+ if escalated != original_model:
1229
+ click.echo(f" escalating model: {original_model} → {escalated}")
1230
+
1231
+ try:
1232
+ new_id = _dispatch_followup(cfg, assignment, briefing, model=escalated)
1233
+ except httpx.HTTPError as e:
1234
+ click.echo(f"error: dispatch failed: {e}", err=True)
1235
+ sys.exit(1)
1236
+ except ValueError as e:
1237
+ click.echo(f"error: {e}", err=True)
1238
+ sys.exit(1)
1239
+
1240
+ click.echo(f"Continuation dispatched (assignment {new_id})")
1241
+ click.echo(f" branch: {assignment.branch or 'unknown'}")
1242
+ click.echo(f" issue: #{assignment.issue_number}: {assignment.issue_title}")
1243
+ click.echo(f" guidance: {guidance}")