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/gate_b.py ADDED
@@ -0,0 +1,411 @@
1
+ """Gate B — post-milestone architecture review (#933, docs/PIPELINE_V2.md).
2
+
3
+ After every issue in a milestone has landed, Gate B dispatches an
4
+ **independent** review that looks at the *assembled* milestone against the
5
+ Gate-A contract (``tests/acceptance/ms-NN/contract.md``, #930) and answers:
6
+ was it implemented to spec? Distinct from the per-issue adversarial code
7
+ review (``coord/review.py``'s ``dispatch_review``, one PR diff at a time) —
8
+ this reviews whether the pieces from separate issues actually integrated and
9
+ whether the assembled result matches what Gate A specified, not whether any
10
+ one diff is individually correct (that already happened at each issue's own
11
+ Review stage).
12
+
13
+ Routed through the **review pipeline**, not ``coord assign`` (docs/
14
+ PIPELINE_V2.md's own wording): the reviewer needs the same independence +
15
+ "coordinator posts to GitHub on your behalf" model as a per-issue review, and
16
+ workers dispatched via ``coord assign`` have ``gh`` denied. Concretely this
17
+ means reusing ``type="review"`` + ``coord.review.REVIEWER_SYSTEM_PROMPT``
18
+ verbatim rather than inventing a new assignment type — which also means
19
+ ``coord.notify``'s existing ``REVIEW_VERDICT``/``REVIEW_BODY`` parsing and
20
+ posting need zero changes: :func:`dispatch_gate_b_review` sets
21
+ ``review_target`` to a non-numeric sentinel (there is no PR to post to, only
22
+ the tracking issue), and ``coord.notify._try_parse_and_post_review`` already
23
+ falls back to posting a plain issue comment whenever ``int(review_target)``
24
+ fails.
25
+
26
+ Like ``coord milestone gate-c`` (#932), dispatching Gate B is an explicit
27
+ manual operator action, and a request-changes verdict is surfaced (as a
28
+ tracking-issue comment) rather than driving anything automatically. Its
29
+ verdict IS consulted, read-only, by ``coord milestone ship`` (#934, Gate D,
30
+ ``coord/commands/milestone.py``) via :func:`latest_gate_b_verdict` — ship
31
+ refuses ``feature/ms-NN -> develop`` until this review is ``"approve"``, but
32
+ shipping itself is still a separate, explicit operator action.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import time
38
+ import uuid
39
+ from typing import TYPE_CHECKING, Callable
40
+
41
+ import httpx
42
+
43
+ from coord import github_ops
44
+ from coord.acceptance import gate_a_contract_path
45
+ from coord.dispatch import AGENT_PORT
46
+ from coord.models import Assignment, Board, Machine, Repo
47
+ from coord.review import REVIEWER_SYSTEM_PROMPT
48
+
49
+ if TYPE_CHECKING:
50
+ from coord.config import Config
51
+ from coord.milestone_order import WorkOrder
52
+
53
+ __all__ = [
54
+ "GateBError",
55
+ "ContractFetch",
56
+ "IssueFetch",
57
+ "review_target_for",
58
+ "build_gate_b_briefing",
59
+ "dispatch_gate_b_review",
60
+ "latest_gate_b_verdict",
61
+ ]
62
+
63
+
64
+ class GateBError(Exception):
65
+ """Gate B's review could not be dispatched (bad machine, network failure)."""
66
+
67
+
68
+ # (repo_github, path, branch) -> file content, or None if missing. Mirrors
69
+ # coord.milestone_dispatch.ManifestFetch — injected so tests never hit `gh`.
70
+ ContractFetch = Callable[[str, str, str], "str | None"]
71
+
72
+
73
+ def _default_fetch_repo_file(repo_github: str, path: str, branch: str) -> str | None:
74
+ try:
75
+ return github_ops.get_repo_file(repo_github, path, branch=branch)
76
+ except RuntimeError:
77
+ return None
78
+
79
+
80
+ # (repo_github, issue_number) -> the `gh issue view --json` dict. Injected so
81
+ # tests never hit `gh`.
82
+ IssueFetch = Callable[[str, int], dict]
83
+
84
+
85
+ def _default_get_issue(repo_github: str, issue_number: int) -> dict:
86
+ return github_ops.get_issue(repo_github, issue_number)
87
+
88
+
89
+ def review_target_for(milestone_number: int) -> str:
90
+ """The non-numeric ``review_target`` sentinel for a Gate B review.
91
+
92
+ Deliberately not an ``int``-parseable string: there is no PR for a
93
+ milestone-level review to post to, only the tracking issue. Keeping this
94
+ non-numeric is what makes ``coord.notify._try_parse_and_post_review``'s
95
+ existing ``int(review_target)`` sniff fall through to its already-built
96
+ "post as an issue comment" path with no code changes there.
97
+ """
98
+ return f"gate-b-ms-{milestone_number}"
99
+
100
+
101
+ def build_gate_b_briefing(
102
+ *,
103
+ tracking_issue: int,
104
+ milestone_number: int,
105
+ tracking_issue_title: str,
106
+ tracking_issue_body: str,
107
+ member_issues: list[tuple[int, str]],
108
+ contract_text: str | None,
109
+ repo_github: str,
110
+ default_branch: str,
111
+ ) -> str:
112
+ """Assemble the Gate B reviewer's prompt. Pure function — easy to test.
113
+
114
+ Unlike ``coord.review.build_review_briefing`` (one issue's PR diff), there
115
+ is no single diff here: the reviewer checks out *default_branch* and
116
+ looks at the assembled state of every issue in *member_issues* against
117
+ *contract_text* (the Gate-A rubric, #930's ``contract.md`` — ``None``
118
+ when the milestone has no oracle-loop contract, falling back to the
119
+ tracking issue body as the rubric).
120
+ """
121
+ lines: list[str] = []
122
+ lines.append(f"# Gate B review: milestone ms-{milestone_number} ({repo_github})")
123
+ lines.append("")
124
+ lines.append(
125
+ "You are an independent architecture reviewer dispatched by the "
126
+ "coordinator for a **post-milestone** review (Gate B, "
127
+ "docs/PIPELINE_V2.md) — distinct from a per-issue code review. Every "
128
+ f"issue below already landed on `{default_branch}` individually and "
129
+ "passed its own Work -> Test -> Review -> Merge pipeline. Your job is "
130
+ "different: look at the **assembled milestone as a whole** and "
131
+ "answer **was it implemented to spec?**"
132
+ )
133
+ lines.append("")
134
+
135
+ lines.append("## Tracking issue")
136
+ lines.append(f"**#{tracking_issue}: {tracking_issue_title}**")
137
+ if tracking_issue_body.strip():
138
+ lines.append("")
139
+ lines.append(tracking_issue_body.strip())
140
+ lines.append("")
141
+
142
+ lines.append("## Issues in this milestone")
143
+ lines.append("")
144
+ for num, title in member_issues:
145
+ lines.append(f"- #{num}: {title}")
146
+ lines.append("")
147
+
148
+ if contract_text and contract_text.strip():
149
+ lines.append("## Gate-A contract (the rubric)")
150
+ lines.append("")
151
+ lines.append(
152
+ "This is the black-box contract authored at Gate A, *before* any "
153
+ "of the above issues were implemented — check the assembled "
154
+ "result against it."
155
+ )
156
+ lines.append("")
157
+ lines.append("```markdown")
158
+ lines.append(contract_text.strip())
159
+ lines.append("```")
160
+ lines.append("")
161
+ else:
162
+ lines.append("## No Gate-A contract found")
163
+ lines.append("")
164
+ lines.append(
165
+ "No `contract.md` was found for this milestone — fall back to "
166
+ "the tracking issue body above (and any design docs it links) as "
167
+ "the rubric."
168
+ )
169
+ lines.append("")
170
+
171
+ lines.append("## What to do")
172
+ lines.append("")
173
+ lines.append(
174
+ f"1. `git fetch origin && git checkout origin/{default_branch}` — you "
175
+ "are reviewing the current assembled state, not a single PR diff."
176
+ )
177
+ lines.append(
178
+ "2. Read the code that implements each issue above (`git log "
179
+ "--oneline --grep '#<N>'`, or search for the files each issue "
180
+ "touched)."
181
+ )
182
+ lines.append(
183
+ "3. Compare the assembled result against the Gate-A contract (and "
184
+ "the tracking issue's design). Specifically look for:"
185
+ )
186
+ lines.append(
187
+ " - Does the implementation match the contract's black-box "
188
+ "surface (CLI names, screen text, API field shapes)?"
189
+ )
190
+ lines.append(
191
+ " - Did the pieces from separate issues actually integrate, or "
192
+ "are there gaps/seams between them?"
193
+ )
194
+ lines.append(
195
+ " - Anything the contract promised that never landed, or landed "
196
+ "differently than specified?"
197
+ )
198
+ lines.append(
199
+ "4. You are NOT re-litigating code style or decisions already "
200
+ "approved in each issue's own review — this is a **spec adherence "
201
+ "and integration** check, not a second code review."
202
+ )
203
+ lines.append(
204
+ "5. Do NOT touch README/CHANGELOG or any other docs, and do not "
205
+ "modify any code — you are read-only, same as a per-issue reviewer."
206
+ )
207
+ lines.append("")
208
+ lines.append(
209
+ "6. At the END of your session, output your verdict in this exact "
210
+ "format (the coordinator posts it to the tracking issue on your "
211
+ "behalf — do NOT run any `gh` commands):"
212
+ )
213
+ lines.append("")
214
+ lines.append("```")
215
+ lines.append("REVIEW_VERDICT: approve")
216
+ lines.append("REVIEW_BODY:")
217
+ lines.append("<your full built-to-spec assessment in markdown>")
218
+ lines.append("END_REVIEW")
219
+ lines.append("```")
220
+ lines.append("")
221
+ lines.append(
222
+ "Use `REVIEW_VERDICT: request-changes` if the assembled milestone "
223
+ "diverges from the Gate-A contract or has integration gaps between "
224
+ "issues — this is the signal that **bounces** the milestone back "
225
+ "for fixes rather than shipping (docs/PIPELINE_V2.md's \"a gate can "
226
+ "bounce backwards\" principle)."
227
+ )
228
+ lines.append(
229
+ "`END_REVIEW` is a HARD REQUIREMENT (#1427): an otherwise-complete "
230
+ "verdict with no `END_REVIEW` line is discarded in its entirety, "
231
+ "not recorded with a best guess. Write `END_REVIEW` as the literal "
232
+ "last line of your final message even if your assessment already "
233
+ "feels finished."
234
+ )
235
+ return "\n".join(lines)
236
+
237
+
238
+ def dispatch_gate_b_review(
239
+ *,
240
+ repo_cfg: Repo,
241
+ config: "Config",
242
+ machine: Machine,
243
+ tracking_issue: int,
244
+ milestone_number: int,
245
+ work_order: "WorkOrder",
246
+ board: Board,
247
+ http_client: httpx.Client | None = None,
248
+ contract_fetch: ContractFetch | None = None,
249
+ issue_fetch: IssueFetch | None = None,
250
+ now: float | None = None,
251
+ ) -> Assignment:
252
+ """Dispatch a Gate B review (#933) to *machine* and record it.
253
+
254
+ Mirrors ``coord.review.dispatch_review``'s raw-POST-to-``/assign`` shape
255
+ (a plain dict payload, not a :class:`~coord.models.Proposal` — a review
256
+ needs ``system_prompt``/``review_target`` fields the dataclass doesn't
257
+ carry) but is standalone rather than keyed off one completed work
258
+ assignment's PR. Reuses ``type="review"`` + ``REVIEWER_SYSTEM_PROMPT``
259
+ verbatim so ``coord.notify``'s existing verdict parsing/posting needs no
260
+ changes — see :func:`review_target_for` for how the no-PR case falls
261
+ through to an issue-comment post automatically.
262
+
263
+ Raises :class:`GateBError` if *machine* has no ``repo_path`` for
264
+ *repo_cfg*, or rejects/can't reach the dispatch. Caller is responsible
265
+ for persisting the board (mirrors ``coord.milestone_dispatch.
266
+ dispatch_entry``'s contract: this appends the new running Assignment to
267
+ *board*'s ``active`` list in place, but ``record_dispatched_assignment``
268
+ already wrote the durable row).
269
+ """
270
+ fetch_contract = contract_fetch or _default_fetch_repo_file
271
+ fetch_issue = issue_fetch or _default_get_issue
272
+
273
+ contract_text = fetch_contract(
274
+ repo_cfg.github, gate_a_contract_path(milestone_number), repo_cfg.default_branch,
275
+ )
276
+
277
+ tracking = fetch_issue(repo_cfg.github, tracking_issue)
278
+ tracking_title = tracking.get("title") or f"Issue #{tracking_issue}"
279
+ tracking_body = tracking.get("body") or ""
280
+
281
+ member_issues: list[tuple[int, str]] = []
282
+ for node in work_order.nodes:
283
+ try:
284
+ data = fetch_issue(repo_cfg.github, node.issue_number)
285
+ title = data.get("title") or f"Issue #{node.issue_number}"
286
+ except RuntimeError:
287
+ title = "(title unavailable)"
288
+ member_issues.append((node.issue_number, title))
289
+
290
+ briefing = build_gate_b_briefing(
291
+ tracking_issue=tracking_issue,
292
+ milestone_number=milestone_number,
293
+ tracking_issue_title=tracking_title,
294
+ tracking_issue_body=tracking_body,
295
+ member_issues=member_issues,
296
+ contract_text=contract_text,
297
+ repo_github=repo_cfg.github,
298
+ default_branch=repo_cfg.default_branch,
299
+ )
300
+
301
+ repo_path = machine.repo_path(repo_cfg.name)
302
+ if repo_path is None:
303
+ raise GateBError(
304
+ f"machine {machine.name!r} has no repo_path configured for {repo_cfg.name!r}"
305
+ )
306
+
307
+ review_target = review_target_for(milestone_number)
308
+ # #1430: deliberately not consulting models.labels — Gate B reviews the
309
+ # *assembled milestone* against the tracking issue, not a single labelled
310
+ # work issue, so there's no single tier label to resolve against.
311
+ payload = {
312
+ "repo_name": repo_cfg.name,
313
+ "repo_path": repo_path,
314
+ "issue_number": tracking_issue,
315
+ "issue_title": f"[gate-b] {tracking_title}",
316
+ "briefing": briefing,
317
+ "files_allowed": [],
318
+ "files_forbidden": [],
319
+ "pull_repos": [],
320
+ "type": "review",
321
+ "model": config.models.default,
322
+ "system_prompt": REVIEWER_SYSTEM_PROMPT,
323
+ "review_target": review_target,
324
+ "branch": repo_cfg.default_branch or "main",
325
+ }
326
+
327
+ client = http_client or httpx
328
+ url = f"http://{machine.host}:{AGENT_PORT}/assign"
329
+ try:
330
+ resp = client.post(url, json=payload, timeout=15)
331
+ resp.raise_for_status()
332
+ agent_response = resp.json()
333
+ except (httpx.HTTPError, httpx.TimeoutException) as exc:
334
+ raise GateBError(
335
+ f"agent {machine.name!r} rejected the Gate B dispatch: {exc}"
336
+ ) from exc
337
+
338
+ assignment = Assignment(
339
+ machine_name=machine.name,
340
+ repo_name=repo_cfg.name,
341
+ issue_number=tracking_issue,
342
+ issue_title=f"[gate-b] {tracking_title}",
343
+ files_allowed=[],
344
+ files_forbidden=[],
345
+ briefing=briefing,
346
+ assignment_id=agent_response.get("id") or uuid.uuid4().hex[:12],
347
+ status="running",
348
+ branch=repo_cfg.default_branch,
349
+ dispatched_at=now if now is not None else time.time(),
350
+ type="review",
351
+ review_target=review_target,
352
+ model=config.models.default,
353
+ )
354
+ board.active.append(assignment)
355
+
356
+ from coord.state import record_dispatched_assignment # noqa: PLC0415
357
+
358
+ record_dispatched_assignment(assignment=assignment, repo_github=repo_cfg.github)
359
+
360
+ try:
361
+ github_ops.post_issue_comment(
362
+ repo_cfg.github,
363
+ tracking_issue,
364
+ "## Gate B: post-milestone architecture review dispatched\n\n"
365
+ f"Reviewer machine: `{machine.name}` \n"
366
+ f"Assignment: `{assignment.assignment_id}`\n\n"
367
+ "This is an independent review of the *assembled* milestone "
368
+ "against its Gate-A contract (docs/PIPELINE_V2.md) — the "
369
+ "verdict will be posted here as a comment when it completes.",
370
+ )
371
+ except RuntimeError:
372
+ # Best-effort — mirrors dispatch_entry's post_briefing posture: a
373
+ # failed announcement comment must never abort an already-accepted
374
+ # dispatch.
375
+ pass
376
+
377
+ return assignment
378
+
379
+
380
+ def latest_gate_b_verdict(
381
+ board: Board, repo_name: str, tracking_issue: int, milestone_number: int,
382
+ ) -> str | None:
383
+ """The most recent Gate B verdict for this milestone, or ``None`` if no
384
+ Gate B review has completed yet.
385
+
386
+ Scans both ``board.completed`` and ``board.active`` (mirrors ``coord.
387
+ merge_queue.has_approved_review``'s posture: a verdict just posted by
388
+ ``coord.notify`` may still be on ``active`` for a tick before reconcile
389
+ moves it) for the ``type="review"`` assignment dispatched by
390
+ :func:`dispatch_gate_b_review` — identified by ``issue_number ==
391
+ tracking_issue`` and ``review_target == review_target_for(milestone_
392
+ number)`` (the ``gate-b-ms-N`` sentinel, unambiguous across milestones
393
+ and repos on the same board). When more than one exists (a re-dispatched
394
+ Gate B after `request-changes`), the most recently dispatched one wins.
395
+
396
+ Used by ``coord milestone ship`` (#934, Gate D) to refuse shipping
397
+ ``feature/ms-NN -> develop`` until Gate B is ``"approve"``.
398
+ """
399
+ target = review_target_for(milestone_number)
400
+ pool = list(getattr(board, "completed", []) or []) + list(getattr(board, "active", []) or [])
401
+ candidates = [
402
+ a for a in pool
403
+ if getattr(a, "type", None) == "review"
404
+ and getattr(a, "repo_name", None) == repo_name
405
+ and getattr(a, "issue_number", None) == tracking_issue
406
+ and getattr(a, "review_target", None) == target
407
+ ]
408
+ if not candidates:
409
+ return None
410
+ candidates.sort(key=lambda a: getattr(a, "dispatched_at", 0) or 0)
411
+ return candidates[-1].review_verdict