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,1523 @@
1
+ """Review-gate commands: `report-result`, `set-review-findings`,
2
+ `fix-briefing`, and the shared `_prompt_and_relay_review_verdict` helper
3
+ used by both the dispatch and sessions modules. Extracted from
4
+ coord/cli.py (#747)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import shlex
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import click
14
+
15
+
16
+ from coord.commands._common import _CONFIG_OPTION, _load_config
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ def _collect_review_body_via_editor(
22
+ *, assignment_id: str, summary: str, pre_body: str | None = None
23
+ ) -> str | None:
24
+ """Open ``$EDITOR`` for the operator to enter the full review findings (#617).
25
+
26
+ Last-resort body capture for :func:`_prompt_and_relay_review_verdict` when
27
+ neither a durable ``coord report-result`` nor the (remote-aware)
28
+ transcript-floor produced the findings. A ``request-changes`` verdict must
29
+ never be recorded bodyless — the write seam refuses it and the fix worker
30
+ would be dispatched with nothing to fix (#607) — so this collects the body
31
+ the operator just wrote in the review session.
32
+
33
+ When *pre_body* is provided it is used as the initial seed, so the operator
34
+ edits or confirms already-recovered findings rather than typing from scratch
35
+ (#877 editor-blank fix).
36
+
37
+ Returns the entered body (template comment lines stripped) or ``None`` when
38
+ empty / no editor available, so the caller can refuse + print the manual
39
+ ``--body-file`` hint.
40
+ """
41
+ template = (
42
+ "\n\n"
43
+ f"# ── Review findings for assignment {assignment_id} ───────────────\n"
44
+ "# Enter the full findings above (Markdown). Every BLOCKING item, with\n"
45
+ "# file:line. This is exactly what the fix worker is briefed with, and\n"
46
+ "# what the #603 per-issue context store records for every future\n"
47
+ "# iteration on this issue.\n"
48
+ "# Lines starting with '#' are ignored. Save an empty file to cancel.\n"
49
+ )
50
+ if pre_body and pre_body.strip():
51
+ seed = pre_body.strip() + "\n" + template
52
+ else:
53
+ seed = (f"{summary.strip()}\n" if summary.strip() else "") + template
54
+ try:
55
+ edited = click.edit(seed)
56
+ except Exception: # noqa: BLE001 — no editor / editor failed → treat as cancel
57
+ edited = None
58
+ if not edited:
59
+ return None
60
+ body = "\n".join(
61
+ ln for ln in edited.splitlines() if not ln.lstrip().startswith("#")
62
+ ).strip()
63
+ return body or None
64
+
65
+
66
+ def _prompt_and_relay_review_verdict(
67
+ *,
68
+ assignment_id: str,
69
+ repo_name: str,
70
+ repo_github: str,
71
+ issue_number: int,
72
+ machine_name: str,
73
+ verdict_cmd_hint: str,
74
+ started_at: float | None = None,
75
+ ssh_target: str | None = None,
76
+ ) -> bool:
77
+ """Prompt the operator for a review verdict on exit and relay it (#486d / #877).
78
+
79
+ Backstop used by BOTH interactive-review exit paths when the reviewer left
80
+ without running `coord report-result` (local or remote — since #590 a
81
+ remote `report-result` routes to the coordinator's shared DB via the daemon,
82
+ so both paths *can* self-report; this prompt only fires when they didn't).
83
+
84
+ Without it the verdict silently never reaches the merge gate and the
85
+ Work→Review→Fix flow stalls. Prompt the operator here (the terminal is a
86
+ TTY) and relay through the same `issue_store` seam `coord report-result`
87
+ uses — which itself routes to the daemon when `board_service` is set.
88
+
89
+ **#877 board-content gate (primary)**: before prompting, read the assignment
90
+ from the local DB for ``review_verdict`` + ``review_findings``. When both
91
+ are present the editor is NOT opened — the already-captured body is used
92
+ directly and the prompt defaults to the captured verdict (not ``[s]``).
93
+ This handles the status=failed-with-valid-verdict inconsistency where the
94
+ board already captured the findings (e.g. via ``notify`` or a previous
95
+ partial attempt) but ``already_recorded`` was False when ``finalize`` ran.
96
+
97
+ **#877 remote-transcript backstop (secondary)**: when the board is empty and
98
+ ``ssh_target`` is set, the remote transcript-floor is re-run against the
99
+ session's own host before any editor is opened. Findings recovered here
100
+ also seed the editor so it is never blank.
101
+
102
+ **#1348 parse-failure diagnostic**: when neither the board nor the transcript
103
+ floor recovered a verdict, but a ``REVIEW_VERDICT:`` marker WAS found in a
104
+ transcript that passes both attribution gates, the operator sees a distinct
105
+ "REVIEW PARSE FAILED" message (not the generic "no verdict reported"), a
106
+ greppable ``log.warning`` with host + path, and the editor opens pre-seeded
107
+ with the recovered excerpt. When the marker line carries a recognisable
108
+ verdict word, the prompt defaults to it instead of ``[s]kip``. The operator
109
+ still confirms; nothing is auto-recorded.
110
+
111
+ *started_at*: session start timestamp (epoch). Passed to the transcript-
112
+ floor so only transcripts active during this session are considered. When
113
+ ``None``, the remote scan uses a permissive ``cutoff=0`` (bounded solely by
114
+ the issue-number gate) and the local diagnostic scan is skipped.
115
+
116
+ *ssh_target*: SSH hostname of the machine where the review session ran.
117
+ ``None`` for local sessions; set to ``machine.host`` for remote reviews.
118
+
119
+ No-op that prints the manual hint when stdin isn't a TTY AND no pre-
120
+ captured data is available (tests / headless invocations where no prior
121
+ recording happened).
122
+ Returns True when a verdict was recorded.
123
+ """
124
+ # ── Step 1: board-content gate (#877) ───────────────────────────────────
125
+ # Check the local DB first (cheapest) for already-captured verdict+findings.
126
+ # This is intentionally BEFORE the TTY check so headless callers also benefit
127
+ # (auto-relay the captured data without prompting).
128
+ _pre_verdict: str | None = None
129
+ _pre_body: str | None = None
130
+ # #1348: diagnostic out-parameter. A non-None list activates unparsed-marker
131
+ # detection in the transcript scans below; the first matching UnparsedReviewMarker
132
+ # (newest transcript) is appended when the strict parse failed.
133
+ _unparsed_markers: list = []
134
+ try:
135
+ from coord.state import load_assignment_review_findings # noqa: PLC0415
136
+
137
+ _cached = load_assignment_review_findings(assignment_id)
138
+ if _cached is not None:
139
+ _pre_verdict, _pre_body = _cached
140
+ except Exception as exc: # noqa: BLE001 — #1349: log + surface, don't swallow
141
+ # A *read* failure (timeout/transport/auth) is NOT the same outcome as
142
+ # "board genuinely has no verdict yet" — collapsing the two here would
143
+ # reproduce the #1349 incident (a read failure silently masquerading as
144
+ # "the agent never reported", re-prompting the operator with zero
145
+ # evidence of which happened). Falling through to Step 2 / the prompt
146
+ # below is still fine — the eventual write is idempotent — but it must
147
+ # not happen silently.
148
+ log.warning(
149
+ "review-verdict board-content gate: could not read cached verdict/"
150
+ "findings for assignment %s: %s", assignment_id, exc,
151
+ )
152
+ click.echo(
153
+ f" warning: could not read the board for a cached review verdict "
154
+ f"on {assignment_id}: {exc}\n"
155
+ " This does NOT mean no verdict was captured — the read itself "
156
+ "failed. Falling back to the prompt below.",
157
+ err=True,
158
+ )
159
+
160
+ # ── Step 2: remote transcript-floor (#877) ──────────────────────────────
161
+ # When board is empty and ssh_target is known, re-run the transcript-floor
162
+ # against the session's own host. Reuses the same floor that
163
+ # finalize_interactive_exit already attempted — a 2nd pass here catches the
164
+ # flush-race / timing window where the JSONL wasn't fully written yet when
165
+ # finalize ran. started_at=None falls through to cutoff=0 (all transcripts
166
+ # in the remote listing) — still bounded by the issue-number gate.
167
+ if _pre_verdict is None and ssh_target:
168
+ try:
169
+ from coord.interactive import ( # noqa: PLC0415
170
+ _review_findings_from_transcript,
171
+ )
172
+
173
+ _tf = _review_findings_from_transcript(
174
+ issue_number, started_at, assignment_id=assignment_id,
175
+ ssh_target=ssh_target,
176
+ _diagnostic=_unparsed_markers, # #1348
177
+ )
178
+ if _tf is not None:
179
+ _pre_verdict = _tf.verdict
180
+ _pre_body = _tf.body
181
+ except Exception: # noqa: BLE001 — ssh unavailable → fall through
182
+ pass
183
+
184
+ # ── Step 2b: local transcript diagnostic scan (#1348) ───────────────────
185
+ # For LOCAL sessions (no ssh_target) the transcript-floor already ran in
186
+ # finalize_interactive_exit without diagnostic collection, so any unparsed
187
+ # marker was silently missed. Re-run here with _unparsed_markers to catch
188
+ # the case — same 2nd-pass rationale as Step 2, same attribution gates,
189
+ # same "first/newest hit only" semantics. Skipped when started_at is None
190
+ # (no bounded window → too likely to match a stale unrelated transcript).
191
+ if _pre_verdict is None and not ssh_target and started_at is not None:
192
+ try:
193
+ from coord.interactive import ( # noqa: PLC0415
194
+ _review_findings_from_transcript,
195
+ )
196
+
197
+ _tf2 = _review_findings_from_transcript(
198
+ issue_number, started_at, assignment_id=assignment_id,
199
+ ssh_target=None,
200
+ _diagnostic=_unparsed_markers, # #1348
201
+ )
202
+ if _tf2 is not None:
203
+ _pre_verdict = _tf2.verdict
204
+ _pre_body = _tf2.body
205
+ except Exception: # noqa: BLE001 — scan failed → fall through
206
+ pass
207
+
208
+ # ── #1348: parse-failure diagnostic ─────────────────────────────────────
209
+ # No verdict recovered, but a REVIEW_VERDICT: marker WAS found in a
210
+ # transcript that passed both attribution gates. This is a different failure
211
+ # from "no verdict reported": the review text IS there; the strict parser
212
+ # rejected it (e.g. bolded markers, missing END_REVIEW — the #1346 incident).
213
+ # Make the distinction operator-visible and surface the excerpt so the editor
214
+ # is never blank.
215
+ #
216
+ # _marker_excerpt: excerpt from the unparsed marker, passed to the editor as
217
+ # pre_body (distinct from _pre_body, which is a fully-captured body that
218
+ # bypasses the editor — the excerpt must always open the editor so the
219
+ # operator can clean it up).
220
+ # _marker_default: normalised verdict for the prompt default.
221
+ _marker_excerpt: str | None = None
222
+ _marker_default: str | None = None
223
+ if _pre_verdict is None and _unparsed_markers:
224
+ _um = _unparsed_markers[0]
225
+ _loc = _um.transcript_path or "(unknown path)"
226
+ _host_desc = f" on {_um.host!r}" if _um.host else ""
227
+ log.warning(
228
+ "[#1348] review parse FAILED for assignment %s: "
229
+ "REVIEW_VERDICT: marker found in transcript %r%s "
230
+ "but _REVIEW_BLOCK_RE rejected it — surfacing excerpt for operator "
231
+ "confirmation (detected verdict word: %r)",
232
+ assignment_id, _loc, _host_desc, _um.verdict_word,
233
+ )
234
+ _inspect_hint = (
235
+ f"ssh {_um.host} cat {shlex.quote(_loc)}" if _um.host
236
+ else f"cat {shlex.quote(_loc)}"
237
+ )
238
+ click.echo(
239
+ f"\n ⚠ REVIEW PARSE FAILED\n"
240
+ f" Transcript: {_loc}{_host_desc}\n"
241
+ " A REVIEW_VERDICT: marker was found in the above transcript but the\n"
242
+ " strict parser could not extract the findings (e.g. bolded markers\n"
243
+ " like **REVIEW_VERDICT:** or a missing END_REVIEW terminator).\n"
244
+ "\n"
245
+ " This is NOT 'no verdict reported' — the review text IS present.\n"
246
+ " The excerpt has been pre-loaded into the editor for confirmation.\n"
247
+ f" To inspect the raw transcript: {_inspect_hint}"
248
+ )
249
+ _marker_excerpt = _um.excerpt
250
+ # Normalize the detected verdict word: aliases (pass→approve,
251
+ # fail→request-changes) and canonical forms are both valid defaults.
252
+ if _um.verdict_word:
253
+ from coord.review import _VERDICT_ALIASES # noqa: PLC0415
254
+ _norm = _VERDICT_ALIASES.get(_um.verdict_word, _um.verdict_word)
255
+ if _norm in ("approve", "request-changes"):
256
+ _marker_default = _norm
257
+
258
+ # ── Surface pre-captured findings ───────────────────────────────────────
259
+ if _pre_verdict is not None:
260
+ click.echo(f"\n Captured review verdict: {_pre_verdict!r}")
261
+ if _pre_body:
262
+ _preview = _pre_body[:300].rstrip()
263
+ if len(_pre_body) > 300:
264
+ _preview += f"\n … ({len(_pre_body)} chars total)"
265
+ click.echo(f" Findings preview: {_preview}\n")
266
+
267
+ # ── Non-TTY path ─────────────────────────────────────────────────────────
268
+ if not sys.stdin.isatty():
269
+ if _pre_verdict is None:
270
+ if _marker_excerpt is not None:
271
+ # #1348: parse failed — different message from "no verdict".
272
+ _um = _unparsed_markers[0]
273
+ _loc = _um.transcript_path or "(unknown path)"
274
+ _host_desc = f" on {_um.host!r}" if _um.host else ""
275
+ click.echo(
276
+ f" review parse FAILED (non-TTY): REVIEW_VERDICT: marker found in "
277
+ f"{_loc!r}{_host_desc} but strict parser rejected it "
278
+ f"(detected verdict: {_um.verdict_word!r}). "
279
+ f"Cannot open editor without a TTY. Recover manually with:\n"
280
+ f"{verdict_cmd_hint}"
281
+ )
282
+ else:
283
+ click.echo(f" no verdict reported — record it with:\n{verdict_cmd_hint}")
284
+ return False
285
+ # Headless + pre-captured: auto-relay (no prompt, no editor).
286
+ # Applies to CI / daemon scenarios where both verdict AND body are
287
+ # already in the board — no human input needed.
288
+ if _pre_verdict == "request-changes" and not _pre_body:
289
+ click.echo(
290
+ " headless: captured verdict is request-changes but findings body "
291
+ f"is missing — record manually with:\n{verdict_cmd_hint}"
292
+ )
293
+ return False
294
+ verdict: str = _pre_verdict
295
+ summary: str = ""
296
+ findings_body: str | None = _pre_body
297
+ else:
298
+ # ── TTY prompt ──────────────────────────────────────────────────────
299
+ # Default: board verdict > detected verdict from unparsed marker > [s]kip.
300
+ # _pre_verdict is the fully-parsed, confirmed one; _marker_default is the
301
+ # marker-line word (only used when _pre_verdict is None — #1348).
302
+ _default = {"approve": "a", "request-changes": "r"}.get(
303
+ _pre_verdict or _marker_default or "", "s"
304
+ )
305
+ ans = click.prompt(
306
+ " Review verdict — [a]pprove / [r]equest-changes / [s]kip",
307
+ type=click.Choice(["a", "r", "s"], case_sensitive=False),
308
+ default=_default,
309
+ show_choices=True,
310
+ )
311
+ verdict = {"a": "approve", "r": "request-changes"}.get(ans.lower(), "")
312
+ if not verdict:
313
+ click.echo(f" skipped — record the verdict later with:\n{verdict_cmd_hint}")
314
+ return False
315
+ summary = click.prompt(
316
+ " one-line summary (optional, Enter to skip)", default="", show_default=False
317
+ )
318
+
319
+ # ── Findings body for request-changes ───────────────────────────────
320
+ # #617: request-changes MUST carry the full findings body.
321
+ # #877: when the board/transcript already has the body, use it directly
322
+ # (no blank editor). When missing, open the editor — seeded with:
323
+ # * the unparsed-marker excerpt (#1348), when the parse failed but
324
+ # we recovered text — so the operator edits what the reviewer wrote;
325
+ # * blank as a last resort, with a hint pointing at the transcript.
326
+ findings_body = None
327
+ if verdict == "request-changes":
328
+ if _pre_body:
329
+ # Pre-captured body — use it directly; editor not opened (#877).
330
+ findings_body = _pre_body
331
+ click.echo(
332
+ f" Using {len(findings_body)}-char findings body from "
333
+ "board/transcript (editor not opened)."
334
+ )
335
+ else:
336
+ # No pre-captured body — open editor, seeded with the recovered
337
+ # marker excerpt if available (#1348), or blank otherwise.
338
+ if _marker_excerpt:
339
+ # Parse-failure case: excerpt already printed above. Editor
340
+ # opens with the recovered text so the operator can clean it up.
341
+ pass
342
+ elif ssh_target:
343
+ click.echo(
344
+ f" Findings not recovered from {ssh_target!r}. "
345
+ "Opening editor — enter the full review (every blocking "
346
+ "item, file:line)."
347
+ )
348
+ click.echo(
349
+ f" To fetch the transcript manually: "
350
+ f"ssh {ssh_target} "
351
+ r"'find $HOME/.claude/projects -name \"*.jsonl\" "
352
+ r"| sort -rn | head -10'"
353
+ )
354
+ else:
355
+ click.echo(
356
+ " request-changes needs your full findings — opening an "
357
+ "editor (every blocking item, file:line)…"
358
+ )
359
+ findings_body = _collect_review_body_via_editor(
360
+ assignment_id=assignment_id, summary=summary,
361
+ pre_body=_marker_excerpt, # #1348: None when no marker was found
362
+ )
363
+ if not findings_body:
364
+ click.echo(
365
+ " verdict NOT recorded: request-changes requires the findings "
366
+ "body — recording it without one would strand the fix worker "
367
+ "(#607). Record it when ready with:\n"
368
+ f" coord report-result --assignment {assignment_id} "
369
+ "--status done --verdict request-changes "
370
+ f"--body-file /tmp/review-{assignment_id}.md",
371
+ err=True,
372
+ )
373
+ return False
374
+
375
+ # ── Relay through issue_store seam ──────────────────────────────────────
376
+ try:
377
+ from coord import issue_store # noqa: PLC0415
378
+
379
+ outcome = issue_store.post_result(
380
+ issue_store.ResultRecord(
381
+ assignment_id=assignment_id,
382
+ machine_name=machine_name,
383
+ repo_name=repo_name,
384
+ repo_github=repo_github,
385
+ issue_number=int(issue_number),
386
+ status="done",
387
+ verdict=verdict, # type: ignore[arg-type] # narrowed to approve/request-changes above
388
+ summary=summary,
389
+ branch=None,
390
+ findings_body=findings_body,
391
+ )
392
+ )
393
+ click.echo(
394
+ f" verdict '{verdict}' recorded (posted_to_github={outcome.posted})."
395
+ )
396
+ if not outcome.findings_written:
397
+ # #650: this is exactly the shape of the original incident — a
398
+ # second exit-prompt capture for a review that already has
399
+ # non-empty findings on the row. The stored verdict already
400
+ # matched (or `_persist_review_verdict` would have raised), so
401
+ # the good findings were preserved — just tell the operator.
402
+ click.echo(
403
+ " note: existing review findings were NOT overwritten "
404
+ "(#650 clobber guard) — this assignment already had "
405
+ "different, non-empty findings recorded; they are "
406
+ "preserved. Use `coord report-result ... --force` if this "
407
+ "overwrite was intentional."
408
+ )
409
+ if outcome.error:
410
+ click.echo(f" github post warning: {outcome.error}", err=True)
411
+ return True
412
+ except Exception as exc: # noqa: BLE001 — best-effort; fall back to the hint
413
+ click.echo(
414
+ f" warning: failed to record verdict inline: {exc}\n{verdict_cmd_hint}",
415
+ err=True,
416
+ )
417
+ return False
418
+
419
+
420
+ def _prompt_and_relay_test_verdict(
421
+ *,
422
+ work_assignment_id: str,
423
+ smoke_assignment_id: str,
424
+ repo_name: str,
425
+ repo_github: str,
426
+ issue_number: int,
427
+ machine_name: str,
428
+ verdict_cmd_hint: str,
429
+ ) -> bool:
430
+ """Prompt the operator for a Test-gate verdict on exit and relay it (#923).
431
+
432
+ Backstop for interactive SMOKE-OF sessions that exit without the agent
433
+ running ``coord test --passed|--fail|--skipped``. Without it the test
434
+ verdict is silently lost, the Test box greys, and the merge gate blocks
435
+ with no error message.
436
+
437
+ **Idempotent**: reads ``work_assignment_id`` from the board first; when
438
+ ``test_state`` is already set (the agent DID run ``coord test``), this
439
+ function prints a confirmation and returns True immediately — no
440
+ double-prompt.
441
+
442
+ **#1349 point lookup**: the idempotency read is a single field off a
443
+ single row, so it uses ``GET /assignment/{id}`` (point endpoint, ~2.7 KB)
444
+ instead of the full ``GET /board`` collection (4.4 MB, 0.7-1.2s to build
445
+ against a 5s client timeout) — mirrors
446
+ :func:`coord.state.load_assignment_review_findings`'s point-lookup-first
447
+ shape, including its 404 compatibility fallback to the collection for a
448
+ pre-#1336 daemon. A read *failure* here is logged and surfaced to the
449
+ operator distinctly from "no verdict recorded" — see the incident writeup
450
+ in #1349: silently falling through to the prompt on any exception erased
451
+ the evidence of what actually happened.
452
+
453
+ Mirrors :func:`_prompt_and_relay_review_verdict` (which handles the REVIEW
454
+ stage). Same contract for headless callers: no-op + hint when stdin is
455
+ not a TTY.
456
+
457
+ *work_assignment_id*: the WORK assignment being tested (passed as
458
+ ``smoke_of`` at dispatch time). The test verdict is ALWAYS recorded on
459
+ the work row, not the smoke session row.
460
+
461
+ *smoke_assignment_id*: the smoke session assignment id (used for log
462
+ messages only).
463
+
464
+ Returns True when a verdict was successfully recorded.
465
+ """
466
+ # Context label for the log lines below — identifies which repo/issue/
467
+ # machine/smoke-session this backstop is acting on (#923 review nit: these
468
+ # params were previously accepted but never read).
469
+ _ctx = (
470
+ f"{repo_name} ({repo_github}) issue #{issue_number} on {machine_name} "
471
+ f"[smoke={smoke_assignment_id}]"
472
+ )
473
+
474
+ # ── Idempotency gate (#1349) ────────────────────────────────────────────
475
+ # Read the WORK row (not the smoke session) and check test_state. If it
476
+ # already has test_state set the agent self-reported — do nothing.
477
+ #
478
+ # Point lookup first (#1349): a thin client hits GET /assignment/{id}
479
+ # instead of paying for the full GET /board collection just to read one
480
+ # field off one row — see load_assignment_review_findings for the model
481
+ # this mirrors, including the 404 compatibility fallback for a
482
+ # pre-#1336 daemon. On the daemon host itself (no board_service
483
+ # configured) the local DB is canonical and this isn't an HTTP round
484
+ # trip at all, so the pre-existing local Board lookup is unchanged.
485
+ #
486
+ # A *read* failure must stay distinguishable from "board says no verdict"
487
+ # — the old code was `except Exception: pass`, which turned a timeout /
488
+ # transport / auth failure into the exact same outcome as "genuinely no
489
+ # verdict yet", silently re-prompting the operator with zero evidence of
490
+ # which happened. That is why the incident that motivated this fix
491
+ # (operator re-typing a verdict 5s after `coord test --passed` had
492
+ # already succeeded) could not be diagnosed from the logs afterwards —
493
+ # the failure erased its own evidence. Re-prompting after a read failure
494
+ # is still fine and safe (the write is idempotent); being SILENT about
495
+ # it is the bug.
496
+ _test_state: str | None = None
497
+ _read_exc: Exception | None = None
498
+ try:
499
+ from coord.board_service import resolve as _resolve_board_service # noqa: PLC0415
500
+
501
+ _svc = _resolve_board_service()
502
+ if _svc is not None:
503
+ from coord.client import fetch_assignment, fetch_board_payload # noqa: PLC0415
504
+
505
+ _row = fetch_assignment(_svc, work_assignment_id)
506
+ if _row is None:
507
+ # 404: unknown id, or a pre-#1336 daemon (an unmatched route
508
+ # is also a 404) — one compatibility pass through the
509
+ # collection payload, exactly like load_assignment_review_findings.
510
+ _payload = fetch_board_payload(_svc)
511
+ _row = next(
512
+ (
513
+ a
514
+ for a in _payload.get("assignments", [])
515
+ if a.get("assignment_id") == work_assignment_id
516
+ ),
517
+ None,
518
+ )
519
+ if _row is not None:
520
+ _test_state = (_row.get("test_state") or "").strip() or None
521
+ else:
522
+ from coord.board_service import read_board as _read_board_tv # noqa: PLC0415
523
+
524
+ _work = _read_board_tv().find_by_id(work_assignment_id)
525
+ if _work is not None:
526
+ _test_state = (_work.test_state or "").strip() or None
527
+ except Exception as exc: # noqa: BLE001 — #1349: log + surface, never swallow
528
+ _read_exc = exc
529
+ log.warning(
530
+ "test-verdict idempotency gate: could not read assignment %s "
531
+ "from the board: %s", work_assignment_id, exc,
532
+ )
533
+
534
+ if _test_state:
535
+ click.echo(
536
+ f" test verdict already recorded: {_test_state!r} for "
537
+ f"{_ctx} (agent used `coord test` — no operator prompt needed)"
538
+ )
539
+ return True
540
+
541
+ if _read_exc is not None:
542
+ click.echo(
543
+ " warning: could not read the board to check for an existing "
544
+ f"test verdict for {_ctx}: {_read_exc}\n"
545
+ " This does NOT mean the agent failed to report — the read "
546
+ "itself failed. Falling back to the prompt below; re-answering "
547
+ "is safe (the write is idempotent).",
548
+ err=True,
549
+ )
550
+
551
+ # ── Non-TTY path ──────────────────────────────────────────────────────────
552
+ if not sys.stdin.isatty():
553
+ click.echo(
554
+ f" no test verdict recorded for {_ctx} — record it with:\n"
555
+ f"{verdict_cmd_hint}"
556
+ )
557
+ return False
558
+
559
+ # ── TTY prompt ────────────────────────────────────────────────────────────
560
+ ans = click.prompt(
561
+ " Test verdict — [p]assed / [f]ailed / [s]kip",
562
+ type=click.Choice(["p", "f", "s"], case_sensitive=False),
563
+ default="s",
564
+ show_choices=True,
565
+ )
566
+ if ans.lower() == "s":
567
+ click.echo(
568
+ f" skipped — record the test verdict later with:\n{verdict_cmd_hint}"
569
+ )
570
+ return False
571
+
572
+ test_state = {"p": "passed", "f": "failed"}[ans.lower()]
573
+ reason: str = ""
574
+ if test_state == "failed":
575
+ reason = click.prompt(
576
+ " failure reason (what was checked, expected vs actual, repro "
577
+ "steps, suspected files — this IS the fix worker's brief)",
578
+ default="",
579
+ show_default=False,
580
+ ).strip()
581
+
582
+ # ── Relay via daemon-routed record_test_verdict ────────────────────────────
583
+ try:
584
+ from coord.state import record_test_verdict as _record_tv # noqa: PLC0415
585
+
586
+ _record_tv(
587
+ assignment_id=work_assignment_id,
588
+ test_state=test_state,
589
+ test_reason=reason if reason else None,
590
+ # Mirror to legacy smoke_test columns (pipeline.py reads both).
591
+ smoke_test="pass" if test_state == "passed" else "fail",
592
+ smoke_test_reason=reason if test_state == "failed" and reason else None,
593
+ )
594
+ click.echo(
595
+ f" test verdict '{test_state}' recorded for work assignment "
596
+ f"{work_assignment_id} ({_ctx})."
597
+ )
598
+ return True
599
+ except Exception as exc: # noqa: BLE001 — best-effort; fall back to the hint
600
+ click.echo(
601
+ f" warning: failed to record test verdict for {_ctx}: {exc}\n"
602
+ f"{verdict_cmd_hint}",
603
+ err=True,
604
+ )
605
+ return False
606
+
607
+
608
+ @click.command(
609
+ "report-result",
610
+ help=(
611
+ "Report the outcome of an interactive session through the "
612
+ "coordinator's issue_store seam (#466). "
613
+ "REQUIRED for review sessions where the verdict can only come "
614
+ "from the agent."
615
+ ),
616
+ )
617
+
618
+
619
+ @click.option(
620
+ "--assignment", "assignment_id_opt", default=None,
621
+ help="The assignment id (defaults to $COORD_ASSIGNMENT_ID).",
622
+ )
623
+
624
+
625
+ @click.option(
626
+ "--status",
627
+ type=click.Choice(["done", "blocked", "already-implemented"]),
628
+ required=True,
629
+ help=(
630
+ "Terminal result: `done` = work landed; `blocked` = cannot proceed; "
631
+ "`already-implemented` = nothing to do (advisory)."
632
+ ),
633
+ )
634
+
635
+
636
+ @click.option(
637
+ "--verdict",
638
+ type=click.Choice(["approve", "request-changes"]),
639
+ default=None,
640
+ help=(
641
+ "Review verdict — only meaningful for review sessions where no "
642
+ "commits are pushed. Recorded so the merge-gate sees the same "
643
+ "field a claude-p reviewer would have populated."
644
+ ),
645
+ )
646
+
647
+
648
+ @click.option(
649
+ "--summary", default="",
650
+ help="One-paragraph summary posted on the issue under the result.",
651
+ )
652
+
653
+
654
+ @click.option(
655
+ "--body-file", "body_file",
656
+ type=click.Path(exists=True, dir_okay=False),
657
+ default=None,
658
+ help=(
659
+ "Path to a file with the FULL findings body (markdown). For a REVIEW "
660
+ "session, write your complete review here and pass this — it is persisted "
661
+ "on the assignment AND posted to the issue under a machine-parseable "
662
+ "marker, so the fix worker is briefed with the actual findings (from any "
663
+ "machine, via the GitHub message bus), not just the one-line --summary. "
664
+ "REQUIRED with `--verdict request-changes` (#580)."
665
+ ),
666
+ )
667
+
668
+
669
+ @click.option(
670
+ "--body", "body_inline", default=None,
671
+ help=(
672
+ "Inline alternative to --body-file (the full findings body as a string, "
673
+ "e.g. --body \"$(cat findings.md)\"). One of --body/--body-file is "
674
+ "required with `--verdict request-changes`."
675
+ ),
676
+ )
677
+
678
+
679
+ @click.option(
680
+ "--audit-json", "audit_json_file",
681
+ type=click.Path(exists=True, dir_okay=False),
682
+ default=None,
683
+ help=(
684
+ "Milestone Outcome Audit (#886 Phase 2) ONLY — path to a JSON file "
685
+ "with the structured verdict: "
686
+ '{"bottom_line": "...", "goals": [{"goal": "...", "metric_before": '
687
+ '"...", "metric_after": "...", "verdict": "met|partial|gap", '
688
+ '"evidence": "..."}]}. Routes the result through the audit dual-write '
689
+ "path (assignment row + epic comment + #603 context store) instead of "
690
+ "the generic done-comment body. Only valid on a --audit-of assignment."
691
+ ),
692
+ )
693
+
694
+
695
+ @click.option(
696
+ "--force",
697
+ is_flag=True,
698
+ default=False,
699
+ help=(
700
+ "Confirm overwriting already-captured review findings for this "
701
+ "assignment (#650). Without it, a --verdict request-changes/--body "
702
+ "write that would replace non-empty existing review_findings with a "
703
+ "different body is refused — a second capture for the same "
704
+ "assignment is, by construction, a re-run (finishing the exit "
705
+ "process twice, a stray reattach), never a legitimate new review."
706
+ ),
707
+ )
708
+
709
+
710
+ @click.option(
711
+ "--verdict-source",
712
+ type=click.Choice(["recovered", "overridden"]),
713
+ default=None,
714
+ help=(
715
+ "#1956: state EXPLICITLY when --verdict is not the reviewer's own "
716
+ "fresh self-report. 'recovered' = the reviewer reached this verdict "
717
+ "and said so in prose, but never emitted the machine-readable "
718
+ "REVIEW_VERDICT header — you rescued it from the transcript. "
719
+ "'overridden' = a human is recording a DIFFERENT verdict than what "
720
+ "the reviewer actually produced (or no reviewer verdict exists at "
721
+ "all). Omit this for the normal case: an agent self-reporting its "
722
+ "own session's verdict before exiting — that is recorded as "
723
+ "'agent' automatically. Requires --verdict-reason."
724
+ ),
725
+ )
726
+
727
+
728
+ @click.option(
729
+ "--verdict-reason",
730
+ default=None,
731
+ help=(
732
+ "Required with --verdict-source: a short justification for the "
733
+ "recovery/override, so the provenance is auditable rather than "
734
+ "reading identically to an agent-produced verdict (#1956)."
735
+ ),
736
+ )
737
+
738
+
739
+ @_CONFIG_OPTION
740
+ def report_result(
741
+ assignment_id_opt: str | None,
742
+ status: str,
743
+ verdict: str | None,
744
+ summary: str,
745
+ body_file: str | None,
746
+ body_inline: str | None,
747
+ audit_json_file: str | None,
748
+ force: bool,
749
+ verdict_source: str | None,
750
+ verdict_reason: str | None,
751
+ config_path: Path,
752
+ ) -> None:
753
+ """``coord report-result --assignment <id> --status <s> [--verdict <v>] --summary <text>``
754
+
755
+ The single coordinator-mediated command an interactive Claude
756
+ session may invoke before it exits. Writes the outcome through the
757
+ :mod:`coord.issue_store` seam (same path the git-floor backstop
758
+ uses), so the GitHub message bus and the local DB see a
759
+ structurally-identical completion regardless of which mechanism
760
+ produced it.
761
+ """
762
+ import os as _os # noqa: PLC0415
763
+
764
+ from coord import issue_store # noqa: PLC0415
765
+ from coord.client import resolve_board_service # noqa: PLC0415
766
+
767
+ assignment_id = assignment_id_opt or _os.environ.get("COORD_ASSIGNMENT_ID")
768
+ if not assignment_id:
769
+ click.echo(
770
+ "error: --assignment is required (or set $COORD_ASSIGNMENT_ID)",
771
+ err=True,
772
+ )
773
+ sys.exit(2)
774
+
775
+ # #886 Phase 2: Milestone Outcome Audit structured verdict. Parsed and
776
+ # validated FIRST — before any board/config resolution — so an obvious
777
+ # malformed-JSON typo fails fast without needing a reachable daemon/config
778
+ # (issue_store._validate_result re-validates server-side too, the one seam
779
+ # every caller funnels through, but this saves the agent a round trip).
780
+ audit_goals: list[dict] | None = None
781
+ audit_bottom_line: str | None = None
782
+ if audit_json_file:
783
+ import json as _json # noqa: PLC0415
784
+
785
+ try:
786
+ audit_payload = _json.loads(Path(audit_json_file).read_text(encoding="utf-8"))
787
+ except (OSError, ValueError) as exc:
788
+ click.echo(
789
+ f"error: could not read/parse --audit-json {audit_json_file!r}: {exc}",
790
+ err=True,
791
+ )
792
+ sys.exit(2)
793
+ if not isinstance(audit_payload, dict) or not isinstance(
794
+ audit_payload.get("goals"), list
795
+ ):
796
+ click.echo(
797
+ "error: --audit-json must be a JSON object with a 'goals' list, "
798
+ 'e.g. {"bottom_line": "...", "goals": [{"goal": "...", '
799
+ '"metric_before": "...", "metric_after": "...", "verdict": '
800
+ '"met|partial|gap", "evidence": "..."}]}',
801
+ err=True,
802
+ )
803
+ sys.exit(2)
804
+ audit_goals = audit_payload["goals"]
805
+ audit_bottom_line = audit_payload.get("bottom_line") or None
806
+ for _goal in audit_goals:
807
+ _verdict = _goal.get("verdict") if isinstance(_goal, dict) else None
808
+ if _verdict not in ("met", "partial", "gap"):
809
+ click.echo(
810
+ f"error: --audit-json goal {_goal!r} has invalid verdict "
811
+ f"{_verdict!r} (expected one of 'met'/'partial'/'gap')",
812
+ err=True,
813
+ )
814
+ sys.exit(2)
815
+
816
+ repo_github: str | None = None
817
+ repo_name: str | None = None
818
+ machine_name: str | None = None
819
+ issue_number: int | None = None
820
+ branch: str | None = None
821
+
822
+ svc = resolve_board_service()
823
+ prefetch_failed = False
824
+ if svc is not None:
825
+ # Thin client (#590): no local DB/config — resolve the assignment's
826
+ # identity from the daemon, then let issue_store.post_result route the
827
+ # write back to the daemon's shared DB. This is what lets a remote
828
+ # interactive session self-report instead of the old "do NOT run
829
+ # report-result" workaround.
830
+ #
831
+ # #1336: this prefetch is an *enrichment*, not a prerequisite — the
832
+ # verdict write MUST NOT be discarded because a read was slow. It
833
+ # uses the point endpoint (GET /assignment/{id}; falls back to the
834
+ # full /board payload only for a pre-#1336 daemon), gets the write
835
+ # timeout (it rides a write operation), and on ANY failure we warn and
836
+ # proceed with the POST: the daemon owns the DB and resolves the
837
+ # identity fields server-side.
838
+ from coord.client import ( # noqa: PLC0415
839
+ _WRITE_TIMEOUT,
840
+ fetch_assignment,
841
+ fetch_board_payload,
842
+ )
843
+
844
+ row = None
845
+ try:
846
+ row = fetch_assignment(svc, assignment_id, timeout=_WRITE_TIMEOUT)
847
+ if row is None:
848
+ # 404: unknown id — or a daemon that predates the point
849
+ # endpoint (an unmatched route is also a 404). One
850
+ # compatibility fallback through the collection payload.
851
+ payload = fetch_board_payload(svc, timeout=_WRITE_TIMEOUT)
852
+ row = next(
853
+ (
854
+ a
855
+ for a in payload.get("assignments", [])
856
+ if a.get("assignment_id") == assignment_id
857
+ ),
858
+ None,
859
+ )
860
+ except Exception as exc: # noqa: BLE001
861
+ prefetch_failed = True
862
+ click.echo(
863
+ f"warning: identity prefetch from {svc.url} failed ({exc}).\n"
864
+ " This is a slow/failed BOARD READ, not a write failure — "
865
+ "proceeding to record the verdict; the board service resolves "
866
+ "the assignment's repo/machine/issue itself (#1336).",
867
+ err=True,
868
+ )
869
+ if row is not None:
870
+ repo_github = row.get("repo_github")
871
+ repo_name = row.get("repo_name")
872
+ machine_name = row.get("machine_name")
873
+ issue_number = row.get("issue_number")
874
+ branch = row.get("branch")
875
+ else:
876
+ from coord.state import build_board, load_dispatched # noqa: PLC0415
877
+
878
+ cfg = _load_config(config_path)
879
+
880
+ # Look up the assignment metadata. Prefer the dispatched ledger
881
+ # because it always has repo_github, then fall back to the live
882
+ # board for in-flight rows that haven't been queried elsewhere.
883
+ record = next(
884
+ (r for r in load_dispatched() if r.get("assignment_id") == assignment_id),
885
+ None,
886
+ )
887
+ if record is not None:
888
+ repo_github = record.get("repo_github")
889
+ repo_name = record.get("repo_name")
890
+ machine_name = record.get("machine_name")
891
+ issue_number = record.get("issue_number")
892
+
893
+ board = build_board()
894
+ assignment_obj = board.find_by_id(assignment_id)
895
+ if assignment_obj is not None:
896
+ repo_name = repo_name or assignment_obj.repo_name
897
+ machine_name = machine_name or assignment_obj.machine_name
898
+ issue_number = issue_number or assignment_obj.issue_number
899
+ branch = assignment_obj.branch
900
+ if repo_github is None:
901
+ repo_cfg = cfg.repo(assignment_obj.repo_name)
902
+ if repo_cfg is not None:
903
+ repo_github = repo_cfg.github
904
+
905
+ # Final fallback: if a config repo matches the recorded repo_name,
906
+ # use its github slug.
907
+ if repo_github is None and repo_name is not None:
908
+ repo_cfg = cfg.repo(repo_name)
909
+ if repo_cfg is not None:
910
+ repo_github = repo_cfg.github
911
+
912
+ if not (repo_github and repo_name and machine_name and issue_number):
913
+ if svc is not None and prefetch_failed:
914
+ # #1336 invariant 4: a failed READ must never discard the WRITE.
915
+ # Post the record with blank identity — the daemon fills it in
916
+ # from its own assignments row (`_enrich_result_identity`).
917
+ click.echo(
918
+ " identity unresolved locally — the board service will "
919
+ "resolve it from its own DB.",
920
+ err=True,
921
+ )
922
+ else:
923
+ click.echo(
924
+ f"error: could not resolve assignment {assignment_id!r} from "
925
+ "board/dispatched ledger; pass --assignment with a known id "
926
+ "or run from the originating coordinator machine.",
927
+ err=True,
928
+ )
929
+ sys.exit(1)
930
+
931
+ findings_body: str | None = None
932
+ if body_file:
933
+ try:
934
+ findings_body = Path(body_file).read_text(encoding="utf-8").strip() or None
935
+ except OSError as exc:
936
+ click.echo(
937
+ f"warning: could not read --body-file {body_file!r}: {exc}",
938
+ err=True,
939
+ )
940
+ if findings_body is None and body_inline and body_inline.strip():
941
+ findings_body = body_inline.strip()
942
+
943
+ # #580: a request-changes verdict MUST carry the reviewer's findings.
944
+ # Recording it with only a one-line --summary silently discards the
945
+ # objections, so the iteration-N+1 fix agent gets dispatched with nothing
946
+ # to fix. Require the body (file or inline) and fail loudly otherwise.
947
+ if verdict == "request-changes" and not findings_body:
948
+ click.echo(
949
+ "error: --verdict request-changes requires the review body — pass "
950
+ "--body-file <path> (or --body \"<text>\") with your full findings "
951
+ "(every blocking item, file:line). The one-line --summary is not "
952
+ "enough; it's what the fix worker is briefed with.\n"
953
+ " Write your findings to a file and re-run, e.g.:\n"
954
+ f" coord report-result --assignment {assignment_id} --status done "
955
+ "--verdict request-changes --summary <one-line> "
956
+ f"--body-file /tmp/review-{assignment_id}.md",
957
+ err=True,
958
+ )
959
+ sys.exit(2)
960
+
961
+ # #1956: fast client-side feedback for the same invariant
962
+ # issue_store._validate_result enforces server-side — a relayed verdict
963
+ # with no stated reason is indistinguishable from an agent's own, so
964
+ # refuse it here before any network/board resolution happens.
965
+ if verdict_source is not None and verdict is None:
966
+ click.echo(
967
+ "error: --verdict-source only makes sense alongside --verdict "
968
+ "(it describes the provenance of the verdict being recorded).",
969
+ err=True,
970
+ )
971
+ sys.exit(2)
972
+ if verdict_source is not None and not (verdict_reason and verdict_reason.strip()):
973
+ click.echo(
974
+ f"error: --verdict-source {verdict_source!r} requires "
975
+ "--verdict-reason — a relayed verdict must carry a reason so "
976
+ "it's auditable, not silently read as agent-produced (#1956).",
977
+ err=True,
978
+ )
979
+ sys.exit(2)
980
+
981
+ # #949: push the worktree's commits BEFORE recording 'done' so completed
982
+ # interactive work is never stranded. report-result is the "done" path for
983
+ # interactive fix/merge/work sessions; when a session declares done here
984
+ # (rather than through finalize_interactive_exit, which is bypassed on a
985
+ # detached/reattached tmux), nothing else pushes: the stale-session reaper
986
+ # skips the already-'done' row, and the worker itself may have been told not
987
+ # to push (e.g. a repo CLAUDE.md). Without this, the commit sits unpushed in
988
+ # ~/.coord/worktrees/<aid> and the next test/review agent tests stale code —
989
+ # the confirmed root cause of #407 and #782. Best-effort, non-force: it
990
+ # fast-forwards real work and safely no-ops on a diverged/rebased branch (the
991
+ # merge agent owns force-pushes). Review sessions have no worktree, so the
992
+ # .exists() guard skips them.
993
+ if status == "done":
994
+ from coord.interactive import _git_push # noqa: PLC0415
995
+ from coord.state import COORD_DIR # noqa: PLC0415
996
+
997
+ _wt = COORD_DIR / "worktrees" / assignment_id
998
+ if _wt.exists():
999
+ _pushed, _perr = _git_push(_wt)
1000
+ if _pushed:
1001
+ click.echo(f" pushed {branch or 'HEAD'} to origin (#949)")
1002
+ else:
1003
+ click.echo(
1004
+ f" ⚠ WARNING: could not push commits for {assignment_id} "
1005
+ f"to origin: {_perr}\n"
1006
+ f" work is committed but UNPUSHED in {_wt}\n"
1007
+ f" push manually: git -C {_wt} push origin HEAD",
1008
+ err=True,
1009
+ )
1010
+
1011
+ record_obj = issue_store.ResultRecord(
1012
+ assignment_id=assignment_id,
1013
+ machine_name=machine_name or "",
1014
+ repo_name=repo_name or "",
1015
+ repo_github=repo_github or "",
1016
+ # 0 = "unresolved" (only reachable on the prefetch-failed thin-client
1017
+ # path above) — the daemon's identity enrichment treats falsy fields
1018
+ # as blanks to fill from its own assignments row.
1019
+ issue_number=int(issue_number) if issue_number else 0,
1020
+ status=status, # type: ignore[arg-type]
1021
+ verdict=verdict, # type: ignore[arg-type]
1022
+ summary=summary,
1023
+ branch=branch,
1024
+ findings_body=findings_body,
1025
+ audit_goals=audit_goals,
1026
+ audit_bottom_line=audit_bottom_line,
1027
+ allow_overwrite_findings=force,
1028
+ verdict_source=verdict_source,
1029
+ verdict_source_reason=verdict_reason,
1030
+ )
1031
+ try:
1032
+ outcome = issue_store.post_result(record_obj)
1033
+ except ValueError as exc:
1034
+ click.echo(f"error: {exc}", err=True)
1035
+ sys.exit(2)
1036
+ except RuntimeError as exc:
1037
+ # #886/#990: the verdict write couldn't be durably confirmed (retries
1038
+ # exhausted, or a readback mismatch) — surface this loudly instead of
1039
+ # reporting success while the merge-gate-critical review_verdict (or
1040
+ # the audit run_number versioning invariant) column never actually
1041
+ # landed. Re-running the identical command is the recovery path.
1042
+ # (A #650 clobber-guard refusal is reported below via
1043
+ # `outcome.findings_written`, not as an exception — the verdict
1044
+ # itself still lands cleanly in that case.)
1045
+ click.echo(f"error: {exc}", err=True)
1046
+ sys.exit(1)
1047
+
1048
+ click.echo(
1049
+ f"result recorded: status={outcome.status} event={outcome.event} "
1050
+ f"posted_to_github={outcome.posted}"
1051
+ )
1052
+ if not outcome.findings_written:
1053
+ click.echo(
1054
+ " note: review findings were NOT overwritten (#650 clobber "
1055
+ "guard) — non-empty findings already existed for this "
1056
+ "assignment and the stored verdict already matches; the "
1057
+ "previously captured findings are preserved. Re-run with "
1058
+ "--force if this overwrite was intentional."
1059
+ )
1060
+ if outcome.error:
1061
+ click.echo(f" github post warning: {outcome.error}", err=True)
1062
+
1063
+
1064
+ @click.command(
1065
+ "set-review-findings",
1066
+ help=(
1067
+ "Write review findings to the DB for a completed review assignment (#587). "
1068
+ "Used by the TUI rework dialog so the fix worker is briefed with the "
1069
+ "reviewer's feedback even when the review ran as a human-attended "
1070
+ "claude-pty session (which produces no parseable log)."
1071
+ ),
1072
+ )
1073
+
1074
+
1075
+ @click.argument("assignment_id")
1076
+ @click.option(
1077
+ "--findings",
1078
+ required=True,
1079
+ help=(
1080
+ "The reviewer's findings, in plain text or markdown. Written as the "
1081
+ "REVIEW_BODY so `_load_review_findings` can serve it from the DB "
1082
+ "cache on the next `coord assign --fix-of` dispatch."
1083
+ ),
1084
+ )
1085
+ @click.option(
1086
+ "--force",
1087
+ is_flag=True,
1088
+ default=False,
1089
+ help=(
1090
+ "Confirm overwriting already-captured, non-empty review findings "
1091
+ "for this assignment (#650) — the TUI rework dialog's write is "
1092
+ "otherwise refused when the row already carries a different "
1093
+ "findings body, so a re-run of the dialog can't silently stomp a "
1094
+ "good capture with a placeholder."
1095
+ ),
1096
+ )
1097
+
1098
+
1099
+ @_CONFIG_OPTION
1100
+ def set_review_findings(
1101
+ assignment_id: str,
1102
+ findings: str,
1103
+ force: bool,
1104
+ config_path: Path,
1105
+ ) -> None:
1106
+ """``coord set-review-findings <id> --findings <text>``
1107
+
1108
+ Persist review findings for a human-attended (claude-pty) review whose
1109
+ verdict was already recorded via ``coord report-result --verdict
1110
+ request-changes``. The DB cache written here is the first source
1111
+ ``_load_review_findings`` checks, so the subsequent ``coord assign
1112
+ --fix-of`` dispatch will read it and brief the fix worker correctly
1113
+ instead of emitting the "(No structured findings were captured)" fallback.
1114
+
1115
+ #650: refuses to replace already-captured, non-empty findings with a
1116
+ different body unless ``--force`` is passed — a single assignment backs
1117
+ exactly one review, so a second, differing write here is a re-run of the
1118
+ dialog, never a legitimate new review.
1119
+ """
1120
+ from coord.state import update_assignment_review_findings # noqa: PLC0415
1121
+
1122
+ findings_text = findings.strip()
1123
+ if not findings_text:
1124
+ click.echo("error: --findings must not be empty", err=True)
1125
+ sys.exit(2)
1126
+
1127
+ written = update_assignment_review_findings(
1128
+ assignment_id,
1129
+ verdict="request-changes",
1130
+ body=findings_text,
1131
+ allow_overwrite=force,
1132
+ )
1133
+ if not written:
1134
+ click.echo(
1135
+ f"refused: assignment {assignment_id} already has different, "
1136
+ "non-empty review findings recorded (#650 clobber guard) — "
1137
+ "re-run with --force to overwrite them.",
1138
+ err=True,
1139
+ )
1140
+ sys.exit(1)
1141
+ click.echo(f"findings recorded for {assignment_id}")
1142
+
1143
+
1144
+ @click.command("fix-briefing")
1145
+ @click.argument("aid")
1146
+ @_CONFIG_OPTION
1147
+ def fix_briefing_cmd(aid: str, config_path: Path) -> None:
1148
+ """Print the briefing a `--fix-of <aid>` fix worker would receive — the
1149
+ per-issue context block + the resolved findings / test-failure story (#603).
1150
+
1151
+ coord-tui shells out to this to preview the fix in the fail→fix / rework
1152
+ confirm dialog so the operator sees exactly what the worker is briefed with
1153
+ before launching. Output is the briefing text ONLY (stdout). AID is either
1154
+ a request-changes REVIEW id or a test-failed WORK id (mirrors --fix-of).
1155
+ """
1156
+ from types import SimpleNamespace
1157
+
1158
+ from coord.auto_loop import _build_fix_briefing, _load_review_findings
1159
+ from coord.board_service import read_board
1160
+ from coord.state import COORD_DIR as _CTX_COORD_DIR, issue_context_block
1161
+
1162
+ cfg = _load_config(config_path)
1163
+ board = read_board()
1164
+ target = board.find_by_id(aid)
1165
+ if target is None:
1166
+ click.echo(f"error: no assignment {aid} on the board.", err=True)
1167
+ sys.exit(2)
1168
+
1169
+ # Mirror the --fix-of fork (cli.py): a test-failed WORK id fixes itself
1170
+ # (findings = test_reason); a request-changes REVIEW id fixes its linked work.
1171
+ fix_from_test_fail = (
1172
+ target.type != "review" and getattr(target, "test_state", None) == "failed"
1173
+ )
1174
+ if fix_from_test_fail:
1175
+ work = target
1176
+ elif target.type == "review":
1177
+ work = (
1178
+ board.find_by_id(target.review_of_assignment_id)
1179
+ if target.review_of_assignment_id else None
1180
+ )
1181
+ else:
1182
+ click.echo(
1183
+ f"error: {aid} is not a fixable target "
1184
+ f"(type={target.type!r}, test_state={getattr(target, 'test_state', None)!r}).",
1185
+ err=True,
1186
+ )
1187
+ sys.exit(2)
1188
+ if work is None or not work.branch:
1189
+ click.echo("error: no linked work assignment with a branch to fix.", err=True)
1190
+ sys.exit(2)
1191
+
1192
+ repo_cfg = cfg.repo(work.repo_name)
1193
+ repo_github = repo_cfg.github if repo_cfg else work.repo_name
1194
+ next_iteration = (work.review_iteration or 0) + 1
1195
+ max_iter = cfg.pipeline.max_review_iterations
1196
+ if fix_from_test_fail:
1197
+ # #1337: the board wire carries a bounded PREVIEW of test_reason; the
1198
+ # briefing quotes it verbatim — read full text via the detail endpoint.
1199
+ from coord.state import load_assignment_test_reason as _load_tr # noqa: PLC0415
1200
+
1201
+ story = (
1202
+ _load_tr(work.assignment_id or "")
1203
+ or getattr(work, "test_reason", None)
1204
+ or ""
1205
+ ).strip()
1206
+ findings_body = (
1207
+ "The manual smoke test FAILED. The operator reported:\n\n"
1208
+ f"> {story}\n\nReproduce the failure, fix the root cause, and "
1209
+ "re-validate before pushing."
1210
+ if story else
1211
+ "The manual smoke test FAILED (no reason text was recorded). Pull the "
1212
+ "branch, reproduce the failure the operator hit, and fix the root "
1213
+ "cause before pushing."
1214
+ )
1215
+ else:
1216
+ _log = _CTX_COORD_DIR / "logs" / f"{aid}.log"
1217
+ try:
1218
+ findings = _load_review_findings(
1219
+ target, str(_log) if _log.exists() else None, None,
1220
+ repo_github=repo_github,
1221
+ )
1222
+ except Exception: # noqa: BLE001
1223
+ findings = None
1224
+ findings_body = (
1225
+ findings.body.strip()
1226
+ if findings and (getattr(findings, "body", "") or "").strip()
1227
+ else (
1228
+ f"(No structured findings were captured for review {aid}.) "
1229
+ f"The review verdict was {target.review_verdict or 'request-changes'!r}. "
1230
+ "Read the reviewer's feedback and address every blocking item "
1231
+ "before pushing."
1232
+ )
1233
+ )
1234
+ fix_briefing = _build_fix_briefing(
1235
+ work, SimpleNamespace(body=findings_body), next_iteration, max_iter
1236
+ )
1237
+ ctx = issue_context_block(work.repo_name, work.issue_number)
1238
+ click.echo(ctx + fix_briefing, nl=False)
1239
+
1240
+
1241
+ def _count_diff_changed_lines(diff_text: str) -> int:
1242
+ """Count content +/- lines in a unified diff, excluding the ``+++``/``---``
1243
+ file-header lines (which start with the same characters but carry no
1244
+ content). Used by ``review-reaffirm`` to size the delta against the
1245
+ ``reviews.reaffirm_max_diff_lines`` sanity bound.
1246
+
1247
+ The header exclusion is **positional**, not a bare prefix test: a header
1248
+ only ever appears outside a hunk body (before the file's first ``@@``),
1249
+ so once a ``@@`` hunk header is seen every ``+``/``-`` line counts until
1250
+ the next ``diff --git`` resets the state. A naive ``startswith("---")``
1251
+ silently drops genuine content whose own source text begins with
1252
+ ``--``/``++`` — a removed Markdown/YAML ``---`` separator renders as
1253
+ ``----``, an added ``++counter`` renders as ``+++counter`` — which would
1254
+ *undercount* the delta and let a diff that should be hard-refused slip
1255
+ under the bound. Where the input isn't recognizable as a unified diff
1256
+ (no ``@@`` at all) this errs toward over-counting, which fails closed.
1257
+ """
1258
+ n = 0
1259
+ in_hunk = False
1260
+ for line in diff_text.splitlines():
1261
+ if line.startswith("@@"):
1262
+ in_hunk = True
1263
+ continue
1264
+ if line.startswith("diff --git "):
1265
+ in_hunk = False
1266
+ continue
1267
+ if not in_hunk and (
1268
+ line.startswith("--- ") or line.startswith("+++ ")
1269
+ or line in ("---", "+++")
1270
+ ):
1271
+ continue # unified-diff file header (never inside a hunk body)
1272
+ if line.startswith("+") or line.startswith("-"):
1273
+ n += 1
1274
+ return n
1275
+
1276
+
1277
+ @click.command(
1278
+ "review-reaffirm",
1279
+ help=(
1280
+ "Re-point a stale-but-content-changed approved review to the branch's "
1281
+ "current head, with an audited reason (#1488) — the sanctioned "
1282
+ "alternative to a full re-review or leaving the merge queue for a "
1283
+ "mechanical conflict-resolution delta."
1284
+ ),
1285
+ )
1286
+ @click.argument("work_assignment_id")
1287
+ @click.option(
1288
+ "--reason",
1289
+ required=True,
1290
+ help=(
1291
+ "Why this delta is safe to reaffirm without a full re-review "
1292
+ "(e.g. 'conflict resolution: merged #1461 + #1472 filters, suite "
1293
+ "green'). Written to the audit trail alongside the SHA anchors."
1294
+ ),
1295
+ )
1296
+ @click.option(
1297
+ "--yes", is_flag=True, default=False,
1298
+ help="Skip the interactive confirmation prompt (for scripting).",
1299
+ )
1300
+ @_CONFIG_OPTION
1301
+ def review_reaffirm(
1302
+ work_assignment_id: str, reason: str, yes: bool, config_path: Path,
1303
+ ) -> None:
1304
+ """``coord review-reaffirm <work_assignment_id> --reason "..."``
1305
+
1306
+ #1488: a content-changing rebase (typically a conflict resolution during
1307
+ `coord merge`) correctly voids `has_approved_review`'s approval — but
1308
+ until now the only sanctioned way past that was a full re-review
1309
+ dispatch, or leaving the merge queue entirely via `gh pr merge` (which
1310
+ left no board-visible record at all). This re-points the approving
1311
+ review row's `review_head_sha`/`review_patch_id` to the branch's current
1312
+ head instead, after showing the exact delta being waved through and
1313
+ requiring confirmation — refusing outright (no override flag) when the
1314
+ delta exceeds `reviews.reaffirm_max_diff_lines`, so this stays an escape
1315
+ hatch for mechanical resolutions, never a review bypass.
1316
+
1317
+ Reuses the exact same eligibility checks `dispatch_scoped_reviews_for_queue`
1318
+ (#1476) uses to decide a *scoped re-review* is safe to dispatch —
1319
+ `coord.merge_queue.has_approved_review`, `find_scoped_review_candidate`
1320
+ **and** the `only_conflict_fix_since_review` guardrail — so this command
1321
+ takes the human-in-the-loop path through that same gap instead of
1322
+ dispatching another `claude -p` review. It splits that guardrail's two
1323
+ failure modes: work/fix rounds dispatched after the approval are refused
1324
+ outright, while a delta no coord-tracked conflict-fix can account for
1325
+ (a hand-run rebase) warns loudly and is stamped as unattributed in the
1326
+ audit row.
1327
+ """
1328
+ from coord import github_ops # noqa: PLC0415
1329
+ from coord import merge_queue as mq # noqa: PLC0415
1330
+ from coord.board_service import read_board # noqa: PLC0415
1331
+ from coord.state import record_review_reaffirm # noqa: PLC0415
1332
+
1333
+ if not reason.strip():
1334
+ click.echo("error: --reason must not be empty", err=True)
1335
+ sys.exit(2)
1336
+
1337
+ cfg = _load_config(config_path)
1338
+ board = read_board()
1339
+ work = board.find_by_id(work_assignment_id)
1340
+ if work is None:
1341
+ click.echo(f"error: assignment {work_assignment_id!r} not found on the board", err=True)
1342
+ sys.exit(1)
1343
+ if not work.branch:
1344
+ click.echo(f"error: assignment {work_assignment_id!r} has no branch recorded", err=True)
1345
+ sys.exit(1)
1346
+
1347
+ repo = cfg.repo(work.repo_name)
1348
+ if repo is None:
1349
+ click.echo(f"error: unknown repo {work.repo_name!r}", err=True)
1350
+ sys.exit(1)
1351
+
1352
+ # Reuse a live merge-queue entry when one exists (the common case — the
1353
+ # operator got here because `coord merge` just refused with
1354
+ # review_required); synthesize a throwaway one otherwise. Either way
1355
+ # branch_head_sha/branch_patch_id are never trusted from load_queue()
1356
+ # (they're transient, recomputed by `process()` on every run, never
1357
+ # persisted) — always refetched live below so the gate is evaluated
1358
+ # against the branch's ACTUAL current head, not a stale in-memory value.
1359
+ items = mq.load_queue()
1360
+ entry = next(
1361
+ (e for e in items if e.repo_github == repo.github and e.branch == work.branch),
1362
+ None,
1363
+ )
1364
+ if entry is None:
1365
+ entry = mq.QueuedMerge(
1366
+ assignment_id=work.assignment_id,
1367
+ repo_name=work.repo_name,
1368
+ repo_github=repo.github,
1369
+ branch=work.branch,
1370
+ target_branch=repo.default_branch,
1371
+ issue_number=work.issue_number,
1372
+ issue_title=work.issue_title or "",
1373
+ )
1374
+
1375
+ entry.branch_head_sha = github_ops.get_branch_sha(entry.repo_github, entry.branch)
1376
+ if entry.branch_head_sha is None:
1377
+ click.echo(
1378
+ f"error: could not resolve the current HEAD sha for branch "
1379
+ f"{entry.branch!r} (gh api failure) — refusing to reaffirm "
1380
+ f"without confirming the branch's actual current state",
1381
+ err=True,
1382
+ )
1383
+ sys.exit(1)
1384
+ entry.branch_patch_id = None # force a fresh backfill below, never trust a stale value
1385
+
1386
+ if mq.has_approved_review(entry, board, github_ops):
1387
+ click.echo(
1388
+ "nothing to reaffirm — an approved review already covers the "
1389
+ "branch's current head (either fresh, or a content-identical "
1390
+ "rebase already carried forward by #1475)."
1391
+ )
1392
+ return
1393
+
1394
+ prior_review = mq.find_scoped_review_candidate(entry, board, github_ops)
1395
+ if prior_review is None:
1396
+ click.echo(
1397
+ "error: no reaffirmable approval found — either this branch was "
1398
+ "never reviewed+approved, or the delta since its last approval "
1399
+ "can't be confirmed (missing patch-id data). A full re-review is "
1400
+ f"required: coord review {work_assignment_id}",
1401
+ err=True,
1402
+ )
1403
+ sys.exit(1)
1404
+
1405
+ # #1488 review round 1: `find_scoped_review_candidate` alone does NOT
1406
+ # establish that the delta is a mechanical rebase — its "voided ONLY by a
1407
+ # content-changing rebase" guarantee comes from the caller pairing it with
1408
+ # `only_conflict_fix_since_review` (exactly what the automated scoped
1409
+ # dispatcher does at coord/review.py's "guardrail: another commit
1410
+ # intervened" check). Without it, a bounce round carrying a genuine second
1411
+ # batch of new logic looks identical to a conflict resolution here, and
1412
+ # would ride through on one y/n keystroke.
1413
+ #
1414
+ # The guardrail's two failure modes are NOT equivalent, so they're split:
1415
+ # * intervening work/fix rounds dispatched after the approval ⇒ new
1416
+ # logic the approval provably never saw ⇒ hard refuse, no override.
1417
+ # * no coord-tracked conflict-fix explains the delta (the operator
1418
+ # rebased by hand — the single most common way to land here, and the
1419
+ # exact gap this escape hatch exists to fill) ⇒ unattributable but not
1420
+ # evidence of new logic ⇒ loud warning above the confirm prompt, and
1421
+ # the fact is stamped into the audit row so the trail records whether
1422
+ # coord could attribute the delta or the human vouched for it alone.
1423
+ intervening = mq.intervening_work_since_review(entry, board, prior_review)
1424
+ if intervening:
1425
+ listed = ", ".join(
1426
+ f"{a.assignment_id} ({a.type})" for a in intervening[:5]
1427
+ )
1428
+ click.echo(
1429
+ f"error: {len(intervening)} work/fix assignment(s) were dispatched "
1430
+ f"AFTER review {prior_review.assignment_id} approved this branch: "
1431
+ f"{listed} — the delta is new logic that approval never saw, not a "
1432
+ f"mechanical conflict resolution. Reaffirmation is refused (no "
1433
+ f"override flag). Dispatch a full re-review instead: "
1434
+ f"coord review {work_assignment_id}",
1435
+ err=True,
1436
+ )
1437
+ sys.exit(1)
1438
+
1439
+ conflict_fix_only = mq.only_conflict_fix_since_review(entry, board, prior_review)
1440
+
1441
+ old_sha = prior_review.review_head_sha
1442
+ new_sha = entry.branch_head_sha
1443
+ diff_text = github_ops.get_compare_diff(entry.repo_github, old_sha, new_sha)
1444
+ if diff_text is None:
1445
+ click.echo(
1446
+ f"error: could not fetch the diff between the approved sha "
1447
+ f"{old_sha!r} and the current head {new_sha!r} (gh api compare "
1448
+ f"failed) — refusing to reaffirm without being able to show "
1449
+ f"what's being waved through",
1450
+ err=True,
1451
+ )
1452
+ sys.exit(1)
1453
+
1454
+ diff_lines = _count_diff_changed_lines(diff_text)
1455
+ max_lines = cfg.reviews.reaffirm_max_diff_lines
1456
+ if max_lines > 0 and diff_lines > max_lines:
1457
+ click.echo(
1458
+ f"error: delta is {diff_lines} changed lines, exceeding "
1459
+ f"reviews.reaffirm_max_diff_lines ({max_lines}) — this is an "
1460
+ f"escape hatch for mechanical conflict resolutions, not a review "
1461
+ f"bypass. Dispatch a full re-review instead: "
1462
+ f"coord review {work_assignment_id}",
1463
+ err=True,
1464
+ )
1465
+ sys.exit(1)
1466
+
1467
+ click.echo(
1468
+ f"Reaffirming review {prior_review.assignment_id} for "
1469
+ f"{work.repo_name}#{work.issue_number} ({work.branch})"
1470
+ )
1471
+ click.echo(f" approved sha: {old_sha}")
1472
+ click.echo(f" current head: {new_sha}")
1473
+ click.echo(f" delta: {diff_lines} changed lines")
1474
+ click.echo(f" reason: {reason}")
1475
+ click.echo(
1476
+ " attribution: "
1477
+ + (
1478
+ "a completed conflict-fix accounts for this delta"
1479
+ if conflict_fix_only
1480
+ else "UNATTRIBUTED — no coord-tracked conflict-fix explains it"
1481
+ )
1482
+ )
1483
+ click.echo()
1484
+ click.echo(github_ops.truncate_diff_text(diff_text))
1485
+ click.echo()
1486
+ if not conflict_fix_only:
1487
+ click.echo(
1488
+ "WARNING: coord has no completed conflict-fix on record for this "
1489
+ "branch since the approval, so it cannot confirm the delta above "
1490
+ "is only a mechanical rebase resolution. Nothing intervened on the "
1491
+ "board (that would have been refused outright) — but you are "
1492
+ "vouching for this diff yourself. Read every line before "
1493
+ "confirming.",
1494
+ err=True,
1495
+ )
1496
+ click.echo()
1497
+
1498
+ if not yes and not click.confirm(
1499
+ "Reaffirm this approval to cover the current head?"
1500
+ ):
1501
+ click.echo("aborted — approval NOT reaffirmed.")
1502
+ sys.exit(1)
1503
+
1504
+ new_patch_id = github_ops.get_branch_patch_id(
1505
+ entry.repo_github, entry.target_branch, entry.branch
1506
+ )
1507
+ try:
1508
+ record_review_reaffirm(
1509
+ review_assignment_id=prior_review.assignment_id,
1510
+ new_head_sha=new_sha,
1511
+ new_patch_id=new_patch_id,
1512
+ reason=reason,
1513
+ conflict_fix_only=conflict_fix_only,
1514
+ )
1515
+ except Exception as e: # noqa: BLE001
1516
+ click.echo(f"error: reaffirm write failed: {e}", err=True)
1517
+ sys.exit(1)
1518
+
1519
+ click.echo(
1520
+ f"Reaffirmed: review {prior_review.assignment_id} now covers "
1521
+ f"{new_sha[:12]} — {work.repo_name}#{work.issue_number} is unblocked "
1522
+ f"for merge."
1523
+ )