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,916 @@
1
+ """``coord acceptance`` — the framework-agnostic oracle-loop runner (#944,
2
+ docs/ORACLE_LOOP.md).
3
+
4
+ Subcommands:
5
+
6
+ - ``coord acceptance run --repo R (--issue N | --all)`` — run the repo's
7
+ declared driver **in-session** (the worker's own warm loop) and print a
8
+ structured pass/fail verdict. Sealed: verdicts only, never test source.
9
+ - ``coord acceptance record --repo R --issue N --sha SHA`` — the
10
+ coordinator's **external** trust gate: re-run the sealed slice against the
11
+ pushed SHA in a throwaway worktree and write the verdict to the board (the
12
+ Acceptance box). Routes the whole command through the daemon (mirrors
13
+ ``coord merge`` / ``coord diagnose`` — the no-local-DB rule), never a bare
14
+ ``save_board``.
15
+ - ``coord acceptance mock <repo> <tracking_issue>`` — Gate A (#930): dispatch
16
+ an independent mock-author that renders a viewable mock + writes
17
+ ``tests/acceptance/ms-NN/contract.md``.
18
+ ``coord.milestone_dispatch.gate_a_status`` blocks the milestone's issue
19
+ dispatch until that contract exists. ``--amend``/``--amend-file`` (#1315)
20
+ instead dispatch a targeted correction to an already-merged contract — the
21
+ properly-typed tool for that, replacing the ``type="work"`` fallback that
22
+ caused #1314.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import subprocess
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ import click
33
+
34
+ from coord import github_ops
35
+ from coord.acceptance import (
36
+ ACCEPTANCE_DIRNAME,
37
+ acceptance_capability_gap,
38
+ build_verdict,
39
+ dump_manifest_error_hint,
40
+ failure_summary,
41
+ load_manifest,
42
+ ms_dir_for_issue,
43
+ test_ids_for_issue,
44
+ )
45
+ from coord.acceptance_drivers import DriverError, run_driver
46
+ from coord.commands._common import _CONFIG_OPTION, _load_config
47
+ from coord.comments import format_needs_attention
48
+ from coord.dispatch import DispatchRefused
49
+
50
+
51
+ @click.group("acceptance")
52
+ def acceptance_group() -> None:
53
+ """The oracle-loop acceptance runner.
54
+
55
+ A thin, framework-agnostic front end over a per-repo driver adapter
56
+ declared in ``coordinator.yml`` (``acceptance.drivers``). ``run`` is what
57
+ a worker calls in its own warm session to check itself against the
58
+ sealed suite; ``record`` is the coordinator's external re-run against a
59
+ pushed SHA — the trust gate a headless worker can't fake.
60
+ """
61
+
62
+
63
+ def _resolve_driver(cfg, repo: str, route_path: str | None = None):
64
+ """Resolve *repo*'s acceptance driver, exit(1) with a clear message when
65
+ none resolves.
66
+
67
+ *route_path* (#1125, repo-root-relative — e.g. ``"coord/foo.py"``)
68
+ selects a route when the repo's driver is routed
69
+ (``acceptance.drivers.<repo>.routes``); it's ignored for a flat
70
+ (unrouted) driver. When the repo IS routed but *route_path* doesn't
71
+ resolve to a route (including ``None``), the error names the missing
72
+ ``--for-path`` rather than the generic "not configured at all" message,
73
+ since those are different operator mistakes.
74
+ """
75
+ driver_cfg = cfg.acceptance.driver_for(repo, route_path)
76
+ if driver_cfg is None:
77
+ if cfg.acceptance.has_driver(repo):
78
+ click.echo(
79
+ f"error: repo {repo!r} has a routed acceptance driver "
80
+ "(acceptance.drivers routes) but no route matched — pass "
81
+ "--for-path to select the subtree (e.g. 'coord/**')",
82
+ err=True,
83
+ )
84
+ else:
85
+ click.echo(
86
+ f"error: no acceptance driver configured for repo {repo!r} "
87
+ "(add it under acceptance.drivers in coordinator.yml)",
88
+ err=True,
89
+ )
90
+ sys.exit(1)
91
+ return driver_cfg
92
+
93
+
94
+ def _check_local_capability(driver_cfg, repo: str, cfg) -> None:
95
+ """Fail loudly (#966) when this host is about to run *repo*'s acceptance
96
+ driver but lacks the capability it declares, and some other configured
97
+ machine has it. There's no remote-exec plumbing yet to actually route
98
+ the run there — see :func:`coord.acceptance.acceptance_capability_gap` —
99
+ so the best available behavior is a clear, actionable error instead of
100
+ silently executing on hardware that may not support the driver.
101
+ """
102
+ gap = acceptance_capability_gap(driver_cfg.capability, repo, cfg)
103
+ if gap is None:
104
+ return
105
+ click.echo(
106
+ f"error: this host lacks the {driver_cfg.capability!r} capability "
107
+ f"required by {repo!r}'s acceptance driver ({driver_cfg.kind}); "
108
+ f"{gap.name!r} has it. Capability-matched remote routing isn't "
109
+ "implemented yet (#966) — run this command on that machine directly.",
110
+ err=True,
111
+ )
112
+ sys.exit(1)
113
+
114
+
115
+ def _scoped_verdict(
116
+ tests: list[dict],
117
+ acceptance_root: Path,
118
+ issue_number: int,
119
+ *,
120
+ entrypoint: str = "",
121
+ ) -> dict:
122
+ """Filter *tests* down to *issue_number*'s manifest slice, or exit(1) with
123
+ a clear message when the manifest / slice doesn't exist yet.
124
+
125
+ #1552: when the manifest's test-ids don't all show up in the driver's
126
+ output, name the failure instead of leaving it as a bare
127
+ ``total=0, green=false``. That combination has exactly one common cause
128
+ on an entry-point-linked driver — the slice was authored but never
129
+ registered in the driver's crate root, so cargo compiled it into
130
+ nothing — and a verdict that only says "not green" sends the next round
131
+ hunting for a test failure that never ran. The missing ids are recorded
132
+ on the verdict as ``missing_ids`` and, when nothing at all ran, as a
133
+ ``reason`` string.
134
+ """
135
+ manifest = load_manifest(acceptance_root)
136
+ if not manifest:
137
+ click.echo(f"error: {dump_manifest_error_hint(acceptance_root)}", err=True)
138
+ sys.exit(1)
139
+ ids = test_ids_for_issue(manifest, issue_number)
140
+ if not ids:
141
+ click.echo(
142
+ f"error: issue #{issue_number} has no acceptance slice in the "
143
+ "manifest yet.",
144
+ err=True,
145
+ )
146
+ sys.exit(1)
147
+ scoped = [t for t in tests if t["id"] in ids]
148
+ verdict = build_verdict(scoped, scope="issue", issue_number=issue_number)
149
+
150
+ missing = sorted(ids - {t["id"] for t in scoped})
151
+ if missing:
152
+ verdict["missing_ids"] = missing
153
+ wiring_hint = (
154
+ f" Register it in the driver's entry point `{entrypoint}` "
155
+ "(acceptance.drivers.<repo>[.routes[]].entrypoint) — a slice that "
156
+ "isn't registered there is compiled into nothing and reports no "
157
+ "tests at all."
158
+ if entrypoint else
159
+ " The slice is not being discovered by the driver's run command."
160
+ )
161
+ if not scoped:
162
+ reason = (
163
+ f"issue #{issue_number}'s manifest lists {len(ids)} test-id(s) "
164
+ f"({', '.join(sorted(ids))}), NONE of which appeared in the "
165
+ "driver output — the slice is authored but never executed, so "
166
+ "this is a wiring failure, not a test failure." + wiring_hint
167
+ )
168
+ verdict["reason"] = reason
169
+ click.echo(f"error: {reason}", err=True)
170
+ else:
171
+ click.echo(
172
+ f"warning: {len(missing)} of issue #{issue_number}'s "
173
+ f"{len(ids)} manifest test-id(s) did not appear in the driver "
174
+ f"output ({', '.join(missing)}) — they never ran." + wiring_hint,
175
+ err=True,
176
+ )
177
+ return verdict
178
+
179
+
180
+ @acceptance_group.command("run")
181
+ @click.option("--repo", required=True, help="Local repo name (coordinator.yml repos[].name).")
182
+ @click.option(
183
+ "--issue", "issue_number", type=int, default=None,
184
+ help="Issue number to scope the verdict to (mutually exclusive with --all).",
185
+ )
186
+ @click.option(
187
+ "--all", "run_all", is_flag=True,
188
+ help="Run + report the full accumulated suite (Gate C) instead of one issue's slice.",
189
+ )
190
+ @click.option(
191
+ "--path", "path_opt", type=click.Path(file_okay=False), default=None,
192
+ help="Repo checkout to run the driver in (default: current directory).",
193
+ )
194
+ @click.option(
195
+ "--for-path", "route_path", default=None,
196
+ help=(
197
+ "Repo-relative path (e.g. 'coord/foo.py') used to resolve a "
198
+ "routed acceptance driver (acceptance.drivers.<repo>.routes) — "
199
+ "required when the repo's driver is routed; unused/ignored for a "
200
+ "flat (unrouted) driver. NOT the same as --path (the checkout dir)."
201
+ ),
202
+ )
203
+ @_CONFIG_OPTION
204
+ def acceptance_run(
205
+ repo: str,
206
+ issue_number: int | None,
207
+ run_all: bool,
208
+ path_opt: str | None,
209
+ route_path: str | None,
210
+ config_path: Path,
211
+ ) -> None:
212
+ """Run REPO's sealed acceptance suite and print a structured verdict.
213
+
214
+ The in-session command a worker runs to check itself: ``coord acceptance
215
+ run --issue N`` iterates against the sealed oracle without needing to see
216
+ inside it — only pass/fail + failure messages are ever printed, never
217
+ test source.
218
+ """
219
+ if not run_all and issue_number is None:
220
+ click.echo("error: pass --issue N or --all", err=True)
221
+ sys.exit(1)
222
+ if run_all and issue_number is not None:
223
+ click.echo("error: --issue and --all are mutually exclusive", err=True)
224
+ sys.exit(1)
225
+
226
+ cfg = _load_config(config_path)
227
+ driver_cfg = _resolve_driver(cfg, repo, route_path)
228
+ _check_local_capability(driver_cfg, repo, cfg)
229
+ cwd = Path(path_opt).expanduser() if path_opt else Path.cwd()
230
+
231
+ # #2038: name the commit this verdict is actually about, and warn (never
232
+ # gate — the worker loop legitimately runs against uncommitted work) when
233
+ # it's behind origin's default branch. Best-effort/non-fatal by design.
234
+ repo_entry = cfg.repo(repo)
235
+ default_branch = (getattr(repo_entry, "default_branch", None) or "main") if repo_entry else "main"
236
+ for line in _checkout_freshness_lines(cwd, repo, default_branch):
237
+ click.echo(line, err=True)
238
+
239
+ # #1125 review finding 2: resolve the `{ms}` template (e.g. a routed
240
+ # `run: "pytest tests/acceptance/{ms}"`) from the issue's manifest-mapped
241
+ # ms-NN dir *before* running, when scoped to one issue. Fails soft to
242
+ # `ms=None` on any manifest read hiccup (malformed YAML, not authored
243
+ # yet) — `_scoped_verdict` below still surfaces a clear error for that
244
+ # case; this must not turn into a crash on its own.
245
+ ms: str | None = None
246
+ if issue_number is not None:
247
+ try:
248
+ ms = ms_dir_for_issue(cwd / ACCEPTANCE_DIRNAME, issue_number)
249
+ except Exception: # noqa: BLE001
250
+ ms = None
251
+
252
+ try:
253
+ result = run_driver(
254
+ driver_cfg.kind, driver_cfg.run, cwd=str(cwd), ms=ms,
255
+ setup_command=driver_cfg.setup,
256
+ )
257
+ except DriverError as e:
258
+ click.echo(f"error: {e}", err=True)
259
+ sys.exit(1)
260
+
261
+ if not result.tests and result.exit_code != 0:
262
+ # Compile error / crash before any test emitted a verdict — surface
263
+ # the driver's raw output so the worker can actually act on it,
264
+ # rather than a bare "0 tests found".
265
+ click.echo(result.raw_output, err=True)
266
+
267
+ if run_all:
268
+ verdict = build_verdict(result.tests, scope="all")
269
+ else:
270
+ verdict = _scoped_verdict(
271
+ result.tests, cwd / ACCEPTANCE_DIRNAME, issue_number,
272
+ entrypoint=driver_cfg.entrypoint,
273
+ )
274
+
275
+ click.echo(json.dumps(verdict, indent=2))
276
+ if verdict["total"] == 0 or not verdict["green"]:
277
+ sys.exit(1)
278
+
279
+
280
+ def _checkout_freshness_lines(cwd: Path, repo: str, default_branch: str) -> list[str]:
281
+ """Best-effort ``acceptance: {repo} @ {sha} ({branch})`` header plus a
282
+ dirty-tree / behind-``origin/{default_branch}`` warning line for each
283
+ condition that applies (#2038).
284
+
285
+ ``coord acceptance run`` answers "is the sealed suite green" for
286
+ *whatever is in the current checkout*, with no indication of which
287
+ commit that is — exactly right for a worker's own warm loop (it
288
+ legitimately runs against uncommitted work), but a false-signal
289
+ generator for a coordinator's red-first / sanity re-run against a
290
+ checkout that quietly fell behind ``origin``: every test 404s or fails
291
+ and nothing in the output names a commit, so it reads as a broken merge
292
+ rather than a stale tree. This stays informational (a warning, never a
293
+ gate) — failing closed here would break the worker loop.
294
+
295
+ Silently returns ``[]`` (no header at all) when *cwd* isn't a git
296
+ checkout, or any git call errors/times out — this must never turn an
297
+ otherwise-successful acceptance run into a crash, and a worker running
298
+ in a network-isolated sandbox shouldn't see a scary "git fetch failed"
299
+ on every single invocation.
300
+ """
301
+ import subprocess # noqa: PLC0415 — mirrors the rest of this module's lazy subprocess imports
302
+
303
+ def _git(*args: str, timeout: float = 10.0) -> subprocess.CompletedProcess | None:
304
+ try:
305
+ return subprocess.run(
306
+ ["git", *args], cwd=str(cwd), capture_output=True, text=True, timeout=timeout,
307
+ )
308
+ except (subprocess.SubprocessError, OSError):
309
+ return None
310
+
311
+ head = _git("rev-parse", "--short=9", "HEAD")
312
+ if head is None or head.returncode != 0:
313
+ return []
314
+ sha = head.stdout.strip()
315
+
316
+ branch_res = _git("branch", "--show-current")
317
+ branch = (branch_res.stdout.strip() if branch_res and branch_res.returncode == 0 else "") \
318
+ or "HEAD (detached)"
319
+
320
+ lines = [f"acceptance: {repo} @ {sha} ({branch})"]
321
+
322
+ status_res = _git("status", "--porcelain")
323
+ if status_res is not None and status_res.returncode == 0 and status_res.stdout.strip():
324
+ lines.append(" ⚠ working tree has uncommitted changes")
325
+
326
+ # Single-branch fetch (never touches the working tree or local branch
327
+ # refs) so the behind-check reflects the ACTUAL remote, not whatever
328
+ # `origin/<default_branch>` happened to be cached from a previous
329
+ # fetch elsewhere — that staleness is exactly what bit #2038.
330
+ fetch_res = _git("fetch", "origin", default_branch, "--quiet", timeout=20.0)
331
+ if fetch_res is not None and fetch_res.returncode == 0:
332
+ count_res = _git("rev-list", "--count", "HEAD..FETCH_HEAD")
333
+ if count_res is not None and count_res.returncode == 0:
334
+ raw = count_res.stdout.strip()
335
+ if raw.isdigit() and int(raw) > 0:
336
+ n = int(raw)
337
+ lines.append(
338
+ f" ⚠ checkout is {n} commit{'s' if n != 1 else ''} behind "
339
+ f"origin/{default_branch} — `git pull` before trusting a "
340
+ "red/green verdict"
341
+ )
342
+ return lines
343
+
344
+
345
+ def _acceptance_worktree_path(repo_name: str, issue_number: int) -> Path:
346
+ """Throwaway worktree path for ``coord acceptance record``'s external
347
+ re-run. Lives under ``~/.coord/acceptance-worktrees/`` — OUTSIDE the base
348
+ checkout, same rationale as ``coord test``'s ``_test_worktree_path``
349
+ (#561): a Build/record must never move the base checkout's branch (it
350
+ doubles as the live editable coordinator source on some machines)."""
351
+ from coord.state import COORD_DIR
352
+
353
+ return COORD_DIR / "acceptance-worktrees" / f"{repo_name}-{issue_number}"
354
+
355
+
356
+ def _remove_acceptance_worktree(repo_dir: Path, wt_path: Path) -> None:
357
+ if not wt_path.exists():
358
+ return
359
+ for args in (
360
+ ["git", "worktree", "remove", "--force", str(wt_path)],
361
+ ["git", "worktree", "prune"],
362
+ ):
363
+ try:
364
+ subprocess.run(
365
+ args, cwd=str(repo_dir), capture_output=True, text=True, timeout=30,
366
+ )
367
+ except (subprocess.SubprocessError, OSError):
368
+ pass
369
+
370
+
371
+ def _acceptance_record_via_daemon(svc, params: dict) -> None:
372
+ """Run ``coord acceptance record`` on the daemon host (where the
373
+ canonical board + the repo checkouts live) and relay its output.
374
+ Mirrors ``_diagnose_via_daemon`` / ``_reconcile_via_daemon``."""
375
+ from coord.client import post_record # noqa: PLC0415
376
+
377
+ try:
378
+ resp = post_record(svc, "/acceptance-record", params, timeout=900.0)
379
+ except Exception as exc: # noqa: BLE001
380
+ click.echo(f"error: acceptance record via daemon failed: {exc}", err=True)
381
+ sys.exit(1)
382
+ output = resp.get("output") or ""
383
+ if output:
384
+ click.echo(output, nl=False)
385
+ if resp.get("error"):
386
+ click.echo(f"error: {resp['error']}", err=True)
387
+ code = resp.get("exit_code") or 0
388
+ if code:
389
+ sys.exit(int(code))
390
+
391
+
392
+ @acceptance_group.command(
393
+ "author",
394
+ help=(
395
+ "Dispatch an independent `type=\"test-author\"` session (#931, "
396
+ "docs/ORACLE_LOOP.md) that authors — or, with --issue, extends — the "
397
+ "sealed feature-level acceptance suite for a milestone from its "
398
+ "Gate-A contract. TRACKING_ISSUE is the milestone's tracking issue "
399
+ "number (same argument `coord milestone order`/`gate-c` take); the "
400
+ "milestone number is resolved from it. Requires "
401
+ "`tests/acceptance/ms-NN/contract.md` to already exist in the repo "
402
+ "(hand-authored, or produced by the mock-author, #930) — the "
403
+ "test-author reads it from its own checkout, it is not dispatched "
404
+ "with the contract text embedded."
405
+ ),
406
+ )
407
+ @click.argument("repo")
408
+ @click.argument("tracking_issue", type=int)
409
+ @click.option(
410
+ "--issue", "issue_number", type=int, default=None,
411
+ help=(
412
+ "Scope to one issue's just-in-time slice instead of the whole "
413
+ "milestone (must be a member of TRACKING_ISSUE's work order)."
414
+ ),
415
+ )
416
+ @click.option(
417
+ "--machine", "machine_override", default=None,
418
+ help="Force a specific machine instead of auto-picking one.",
419
+ )
420
+ @click.option(
421
+ "--for-path", "route_path", default=None,
422
+ help=(
423
+ "Repo-relative path (e.g. 'coord/foo.py') used to resolve a "
424
+ "routed acceptance driver (acceptance.drivers.<repo>.routes) — "
425
+ "required when the repo's driver is routed; unused/ignored for a "
426
+ "flat (unrouted) driver."
427
+ ),
428
+ )
429
+ @click.option(
430
+ "--interactive", is_flag=True,
431
+ help=(
432
+ "#1173: run the test-authoring session as a HUMAN-ATTENDED "
433
+ "`claude` (provider `claude-pty`) instead of dispatching a "
434
+ "headless `claude -p` worker — same shape as `coord assign "
435
+ "--interactive`'s --smoke-of/--merge-of/etc flavours. The "
436
+ "independence contract (zero shared context with the "
437
+ "implementation, contract-only) is unchanged; only who "
438
+ "supervises the authoring changes."
439
+ ),
440
+ )
441
+ @click.option(
442
+ "--dry-run", "dry_run", is_flag=True,
443
+ help="With --interactive: resolve everything and print what would run, but don't launch.",
444
+ )
445
+ @_CONFIG_OPTION
446
+ def acceptance_author(
447
+ repo: str,
448
+ tracking_issue: int,
449
+ issue_number: int | None,
450
+ machine_override: str | None,
451
+ route_path: str | None,
452
+ interactive: bool,
453
+ dry_run: bool,
454
+ config_path: Path,
455
+ ) -> None:
456
+ """Dispatch the independent test-author for REPO's milestone."""
457
+ cfg = _load_config(config_path)
458
+
459
+ if interactive:
460
+ from coord.test_author import dispatch_test_author_interactive
461
+
462
+ try:
463
+ exit_code = dispatch_test_author_interactive(
464
+ repo,
465
+ tracking_issue,
466
+ cfg,
467
+ issue_number=issue_number,
468
+ machine_override=machine_override,
469
+ path=route_path,
470
+ dry_run=dry_run,
471
+ )
472
+ except DispatchRefused as e:
473
+ # #2063: the Gate-A sign-off refusal is deterministic and
474
+ # operator-fixable (`coord gate-a --approved`), not a crash —
475
+ # exit EXIT_DISPATCH_REFUSED (not the generic 1 below) so
476
+ # `coord drive`'s subprocess boundary and `coord drive-queue`'s
477
+ # tick can tell it apart and PARK the entry (#1891/#1892)
478
+ # instead of burning attempts toward terminal `blocked` (#2040).
479
+ # Mirrors `coord fix`'s/`coord assign`'s existing
480
+ # `except DispatchRefused` handling.
481
+ from coord.drive import EXIT_DISPATCH_REFUSED # noqa: PLC0415
482
+
483
+ click.echo(f"error: {e}", err=True)
484
+ sys.exit(EXIT_DISPATCH_REFUSED)
485
+ except RuntimeError as e:
486
+ click.echo(f"error: {e}", err=True)
487
+ sys.exit(1)
488
+ sys.exit(exit_code)
489
+
490
+ if dry_run:
491
+ click.echo("error: --dry-run requires --interactive", err=True)
492
+ sys.exit(2)
493
+
494
+ from coord.test_author import dispatch_test_author
495
+
496
+ try:
497
+ assignment_id, machine_name = dispatch_test_author(
498
+ repo,
499
+ tracking_issue,
500
+ cfg,
501
+ issue_number=issue_number,
502
+ machine_override=machine_override,
503
+ path=route_path,
504
+ )
505
+ except DispatchRefused as e:
506
+ # #2063: see the matching --interactive branch above — same
507
+ # deterministic-refusal exit code so the headless dispatch path
508
+ # parks instead of blocking too.
509
+ from coord.drive import EXIT_DISPATCH_REFUSED # noqa: PLC0415
510
+
511
+ click.echo(f"error: {e}", err=True)
512
+ sys.exit(EXIT_DISPATCH_REFUSED)
513
+ except RuntimeError as e:
514
+ click.echo(f"error: {e}", err=True)
515
+ sys.exit(1)
516
+
517
+ scope = f"issue #{issue_number} slice" if issue_number is not None else "full milestone"
518
+ click.echo(
519
+ f"Dispatched test-author {assignment_id} to {machine_name} for "
520
+ f"{repo} (tracking issue #{tracking_issue}, {scope})."
521
+ )
522
+
523
+
524
+ @acceptance_group.command("record")
525
+ @click.option("--repo", required=True, help="Local repo name (coordinator.yml repos[].name).")
526
+ @click.option("--issue", "issue_number", type=int, required=True, help="Issue number.")
527
+ @click.option(
528
+ "--sha", "sha", required=True,
529
+ help="Commit SHA to check out and re-run the sealed suite against — the trust gate.",
530
+ )
531
+ @click.option(
532
+ "--for-path", "route_path", default=None,
533
+ help=(
534
+ "Repo-relative path (e.g. 'coord/foo.py') used to resolve a "
535
+ "routed acceptance driver (acceptance.drivers.<repo>.routes) — "
536
+ "required when the repo's driver is routed; unused/ignored for a "
537
+ "flat (unrouted) driver."
538
+ ),
539
+ )
540
+ @_CONFIG_OPTION
541
+ def acceptance_record(
542
+ repo: str,
543
+ issue_number: int,
544
+ sha: str,
545
+ route_path: str | None,
546
+ config_path: Path,
547
+ ) -> None:
548
+ """Re-run REPO's issue-N acceptance slice externally against SHA and
549
+ write the verdict to the board (the Acceptance box).
550
+
551
+ A headless worker can lie about "green" in its own session; it can't
552
+ fake the coordinator re-running the sealed suite itself, against the
553
+ exact SHA it pushed, in a throwaway worktree the worker never touches.
554
+ """
555
+ from coord.board_service import daemon_reroute_target # noqa: PLC0415
556
+
557
+ # #944: the canonical board + the repo checkouts live on the daemon host,
558
+ # so a thin client routes the ENTIRE record run there (mirrors `coord
559
+ # merge` / `coord diagnose` — never a bare save_board from a thin
560
+ # client's empty local DB). COORD_ACCEPTANCE_ON_DAEMON guards the daemon
561
+ # against re-routing to itself (set by the /acceptance-record server
562
+ # route before it calls this callback directly).
563
+ svc = daemon_reroute_target("COORD_ACCEPTANCE_ON_DAEMON")
564
+ if svc is not None:
565
+ _acceptance_record_via_daemon(
566
+ svc,
567
+ {
568
+ "repo": repo, "issue": issue_number, "sha": sha,
569
+ "for_path": route_path,
570
+ },
571
+ )
572
+ return
573
+
574
+ _acceptance_record_local(repo, issue_number, sha, config_path, route_path)
575
+
576
+
577
+ def _acceptance_record_local(
578
+ repo: str,
579
+ issue_number: int,
580
+ sha: str,
581
+ config_path: Path,
582
+ route_path: str | None = None,
583
+ ) -> None:
584
+ from coord.test_orchestrator import find_local_repo_path # noqa: PLC0415
585
+
586
+ cfg = _load_config(config_path)
587
+ driver_cfg = _resolve_driver(cfg, repo, route_path)
588
+ _check_local_capability(driver_cfg, repo, cfg)
589
+
590
+ repo_dir = find_local_repo_path(repo, cfg)
591
+ if repo_dir is None or not repo_dir.exists():
592
+ click.echo(
593
+ f"error: no local repo checkout found for {repo!r} "
594
+ "(repo_paths in coordinator.yml)",
595
+ err=True,
596
+ )
597
+ sys.exit(1)
598
+
599
+ wt_path = _acceptance_worktree_path(repo, issue_number)
600
+ click.echo(
601
+ f"Fetching origin and preparing acceptance worktree at {sha!r} "
602
+ f"(base checkout {repo_dir} stays untouched)..."
603
+ )
604
+ try:
605
+ subprocess.run(
606
+ ["git", "fetch", "origin", "--prune"], cwd=str(repo_dir),
607
+ check=True, capture_output=True, text=True,
608
+ )
609
+ except subprocess.CalledProcessError as e:
610
+ click.echo(f"error: git fetch failed: {e.stderr.strip()}", err=True)
611
+ sys.exit(1)
612
+
613
+ _remove_acceptance_worktree(repo_dir, wt_path)
614
+ wt_path.parent.mkdir(parents=True, exist_ok=True)
615
+ res = subprocess.run(
616
+ ["git", "worktree", "add", "--force", "--detach", str(wt_path), sha],
617
+ cwd=str(repo_dir), capture_output=True, text=True,
618
+ )
619
+ if res.returncode != 0:
620
+ click.echo(
621
+ f"error: could not create acceptance worktree at {sha!r}: "
622
+ f"{res.stderr.strip()}",
623
+ err=True,
624
+ )
625
+ sys.exit(1)
626
+
627
+ # #1125 review finding 2: resolve `{ms}` from the issue's manifest-mapped
628
+ # ms-NN dir (the worktree just checked out at `sha`) before running —
629
+ # same fail-soft-to-None rationale as `acceptance_run` above.
630
+ ms: str | None = None
631
+ try:
632
+ ms = ms_dir_for_issue(wt_path / ACCEPTANCE_DIRNAME, issue_number)
633
+ except Exception: # noqa: BLE001
634
+ ms = None
635
+
636
+ try:
637
+ result = run_driver(
638
+ driver_cfg.kind, driver_cfg.run, cwd=str(wt_path), ms=ms,
639
+ setup_command=driver_cfg.setup,
640
+ )
641
+ except DriverError as e:
642
+ click.echo(f"error: {e}", err=True)
643
+ _remove_acceptance_worktree(repo_dir, wt_path)
644
+ sys.exit(1)
645
+
646
+ # #944 review: _scoped_verdict exits(1) internally for a manifest that
647
+ # hasn't been authored yet / has no slice for this issue — a
648
+ # configuration error, not a real (kept-for-inspection) test failure, so
649
+ # the throwaway worktree must still be cleaned up on the way out.
650
+ try:
651
+ verdict = _scoped_verdict(
652
+ result.tests, wt_path / ACCEPTANCE_DIRNAME, issue_number,
653
+ entrypoint=driver_cfg.entrypoint,
654
+ )
655
+ except SystemExit:
656
+ _remove_acceptance_worktree(repo_dir, wt_path)
657
+ raise
658
+
659
+ from coord.board_service import read_board # noqa: PLC0415
660
+ from coord.diagnose import stage_assignments # noqa: PLC0415
661
+
662
+ board = read_board()
663
+ work_rows = stage_assignments(board, repo, issue_number, "work")
664
+ if not work_rows:
665
+ click.echo(
666
+ f"error: no work assignment found for {repo} #{issue_number}; "
667
+ "cannot record verdict",
668
+ err=True,
669
+ )
670
+ # Same rationale: a lookup error, not a failing-verdict "kept for
671
+ # inspection" case — don't leak the worktree.
672
+ _remove_acceptance_worktree(repo_dir, wt_path)
673
+ sys.exit(1)
674
+ assignment_id = work_rows[0].assignment_id
675
+
676
+ from coord.state import record_acceptance_verdict # noqa: PLC0415
677
+
678
+ acceptance_state = "passed" if verdict["green"] else "failed"
679
+ reason = failure_summary(verdict) or None
680
+ record_acceptance_verdict(
681
+ assignment_id=assignment_id,
682
+ acceptance_state=acceptance_state,
683
+ acceptance_reason=reason,
684
+ acceptance_sha=sha,
685
+ # #932: per-test counts so the Acceptance box can show partial
686
+ # progress ("3/7 acceptance green") instead of a bare verdict.
687
+ acceptance_total=verdict["total"],
688
+ acceptance_passed=verdict["passed"],
689
+ )
690
+
691
+ click.echo(json.dumps(verdict, indent=2))
692
+ click.echo(f"\nAcceptance {acceptance_state.upper()} for {repo} #{issue_number} @ {sha}")
693
+
694
+ if acceptance_state == "passed":
695
+ _remove_acceptance_worktree(repo_dir, wt_path)
696
+ else:
697
+ click.echo(f" worktree kept for inspection: {wt_path}")
698
+ sys.exit(1)
699
+
700
+
701
+ def _stall_push_wip_snapshot(cwd: Path) -> str:
702
+ """Best-effort WIP snapshot push (#846 worker self-report).
703
+
704
+ Not the coordinator's remote-exec finalize path
705
+ (``coord.interactive.finalize_remote_interactive_exit`` — that's for a
706
+ *remote* interactive fix session over ssh, the wrong shape here since
707
+ this runs inside the worker's own local checkout) — just a plain
708
+ ``git push`` of whatever is on the current branch, so nothing is lost if
709
+ the coordinator takes over. Never raises: a worker calling ``stall`` is
710
+ already stuck, and a push failure shouldn't block the rest of the
711
+ report.
712
+ """
713
+ try:
714
+ branch_res = subprocess.run(
715
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
716
+ cwd=str(cwd), capture_output=True, text=True, timeout=15,
717
+ )
718
+ except (subprocess.SubprocessError, OSError) as exc:
719
+ return f"WIP push skipped: could not resolve branch ({exc})."
720
+ branch = branch_res.stdout.strip()
721
+ if branch_res.returncode != 0 or not branch or branch == "HEAD":
722
+ return "WIP push skipped: not on a branch (detached HEAD)."
723
+ try:
724
+ push_res = subprocess.run(
725
+ ["git", "push", "origin", f"HEAD:{branch}"],
726
+ cwd=str(cwd), capture_output=True, text=True, timeout=60,
727
+ )
728
+ except (subprocess.SubprocessError, OSError) as exc:
729
+ return f"WIP push failed (branch `{branch}`): {exc}"
730
+ if push_res.returncode == 0:
731
+ return f"WIP snapshot pushed to `{branch}`."
732
+ return f"WIP push failed (branch `{branch}`): {push_res.stderr.strip()[:200]}"
733
+
734
+
735
+ @acceptance_group.command(
736
+ "stall",
737
+ help=(
738
+ "Worker self-report (#846, preferred over a coordinator wall-clock "
739
+ "backstop): call this when your acceptance slice for REPO #ISSUE "
740
+ "isn't converging — the failing-set churns rather than shrinks "
741
+ "across >=2 rounds. Records a pinned #603 context note, "
742
+ "best-effort pushes a WIP snapshot of the current branch, and "
743
+ "posts the same one-shot 'needs attention' GitHub comment the "
744
+ "coordinator's backstop (coord.notify.detect_needs_attention) "
745
+ "would otherwise post later. This is the 'stop grinding and "
746
+ "report it' step the oracle-loop contract "
747
+ "(coord.acceptance.oracle_loop_contract_block) points workers at."
748
+ ),
749
+ )
750
+ @click.option("--repo", required=True, help="Local repo name (coordinator.yml repos[].name).")
751
+ @click.option("--issue", "issue_number", type=int, required=True, help="Issue number.")
752
+ @click.option(
753
+ "--tried", required=True,
754
+ help="What you tried across the churning rounds (one or two sentences).",
755
+ )
756
+ @click.option(
757
+ "--stuck", required=True,
758
+ help="Which test id(s)/behavior are still failing and why, as best understood.",
759
+ )
760
+ @click.option(
761
+ "--path", "path_opt", type=click.Path(file_okay=False), default=None,
762
+ help="Repo checkout to push the WIP snapshot from (default: current directory).",
763
+ )
764
+ @_CONFIG_OPTION
765
+ def acceptance_stall(
766
+ repo: str,
767
+ issue_number: int,
768
+ tried: str,
769
+ stuck: str,
770
+ path_opt: str | None,
771
+ config_path: Path,
772
+ ) -> None:
773
+ """Report that REPO #ISSUE's acceptance slice isn't converging."""
774
+ from coord.board_service import read_board # noqa: PLC0415
775
+ from coord.diagnose import stage_assignments # noqa: PLC0415
776
+ from coord.state import add_issue_context_entry, mark_needs_attention_notified # noqa: PLC0415
777
+
778
+ cfg = _load_config(config_path)
779
+ repo_entry = cfg.repo(repo)
780
+ if repo_entry is None:
781
+ click.echo(f"error: unknown repo {repo!r}", err=True)
782
+ sys.exit(2)
783
+
784
+ cwd = Path(path_opt).expanduser() if path_opt else Path.cwd()
785
+ push_note = _stall_push_wip_snapshot(cwd)
786
+
787
+ tried = tried.strip()
788
+ stuck = stuck.strip()
789
+ note = f"Acceptance stall reported. Tried: {tried} Stuck: {stuck} {push_note}".strip()
790
+ add_issue_context_entry(repo, issue_number, note, pinned=True, source="acceptance-stall")
791
+
792
+ board = read_board()
793
+ work_rows = stage_assignments(board, repo, issue_number, "work")
794
+ work = work_rows[0] if work_rows else None
795
+
796
+ body = format_needs_attention(
797
+ assignment_id=(work.assignment_id if work else None) or "",
798
+ machine_name=(work.machine_name if work else None) or "(self-reported)",
799
+ repo_name=repo,
800
+ issue_number=issue_number,
801
+ reason="non_convergence",
802
+ detail=(
803
+ "Acceptance slice not converging (worker self-report).\n\n"
804
+ f"**Tried:** {tried}\n\n**Stuck:** {stuck}\n\n{push_note}"
805
+ ),
806
+ )
807
+ try:
808
+ github_ops.post_issue_comment(repo_entry.github, issue_number, body)
809
+ except Exception as exc: # noqa: BLE001 — the context note above already
810
+ # landed; a comment-post failure shouldn't turn this into a hard error.
811
+ click.echo(f"warning: could not post needs-attention comment: {exc}", err=True)
812
+ else:
813
+ # #846 review: share the notified-ledger with the coordinator's
814
+ # wall-clock backstop (coord.notify.detect_needs_attention) so this
815
+ # self-report is a true one-shot — otherwise the same assignment
816
+ # stays eligible and can get a second "needs attention" comment
817
+ # later. Skip when no work assignment id was resolved (matches the
818
+ # existing blank-assignment-id test case).
819
+ if work is not None:
820
+ mark_needs_attention_notified(work.assignment_id)
821
+
822
+ click.echo(f"Recorded acceptance stall for {repo} #{issue_number}.")
823
+ click.echo(f" {push_note}")
824
+
825
+
826
+ @acceptance_group.command(
827
+ "mock",
828
+ help=(
829
+ "Gate A (#930, docs/ORACLE_LOOP.md): dispatch an independent "
830
+ "mock-author agent that renders a viewable mock of the milestone's "
831
+ "user-facing surface and writes tests/acceptance/ms-NN/contract.md "
832
+ "— the black-box contract the milestone's workers and the "
833
+ "independent test-author (#931) implement/test to. REPO is the "
834
+ "local repo name from coordinator.yml; TRACKING_ISSUE is the GH "
835
+ "issue number of the milestone's tracking issue (must carry a "
836
+ "milestone). `coord milestone dispatch` refuses this milestone's "
837
+ "issues until the contract this produces exists. Pass --amend (or "
838
+ "--amend-file) to instead dispatch a targeted correction to an "
839
+ "ALREADY-MERGED contract — the properly-typed tool for that #1315 "
840
+ "adds, replacing the type=\"work\" fallback that caused #1314."
841
+ ),
842
+ )
843
+ @click.argument("repo")
844
+ @click.argument("tracking_issue", type=int)
845
+ @click.option(
846
+ "--machine",
847
+ default=None,
848
+ help="Override machine selection (default: first idle machine that lists the repo).",
849
+ )
850
+ @click.option(
851
+ "--for-path", "route_path", default=None,
852
+ help=(
853
+ "Repo-relative path (e.g. 'coord/foo.py') used to resolve a "
854
+ "routed acceptance driver (acceptance.drivers.<repo>.routes) — "
855
+ "required when the repo's driver is routed; unused/ignored for a "
856
+ "flat (unrouted) driver."
857
+ ),
858
+ )
859
+ @click.option(
860
+ "--amend", "amend_text", default=None,
861
+ help=(
862
+ "#1315: targeted amendment mode — dispatch a narrow mock-author "
863
+ "session that corrects the ALREADY-MERGED contract.md/mocks under "
864
+ "tests/acceptance/ms-NN/, using this exact text as the correction "
865
+ "to make, instead of doing a full fresh render from the "
866
+ "milestone's open issues. This is the properly-typed replacement "
867
+ "for falling back to a plain `coord assign` (type=\"work\") to fix "
868
+ "a small contract mistake (#1314's root cause). Mutually "
869
+ "exclusive with --amend-file."
870
+ ),
871
+ )
872
+ @click.option(
873
+ "--amend-file", "amend_file", type=click.Path(exists=True, dir_okay=False, path_type=Path),
874
+ default=None,
875
+ help="Read the --amend correction text from a file instead of the command line.",
876
+ )
877
+ @_CONFIG_OPTION
878
+ def acceptance_mock_cmd(
879
+ repo: str,
880
+ tracking_issue: int,
881
+ machine: str | None,
882
+ route_path: str | None,
883
+ amend_text: str | None,
884
+ amend_file: Path | None,
885
+ config_path: Path,
886
+ ) -> None:
887
+ if amend_text is not None and amend_file is not None:
888
+ click.echo("error: --amend and --amend-file are mutually exclusive", err=True)
889
+ sys.exit(2)
890
+ if amend_file is not None:
891
+ amend_text = amend_file.read_text()
892
+
893
+ cfg = _load_config(config_path)
894
+ repo_entry = cfg.repo(repo)
895
+ if repo_entry is None:
896
+ click.echo(f"error: unknown repo {repo!r}", err=True)
897
+ sys.exit(2)
898
+
899
+ from coord.mock_author import dispatch_acceptance_mock
900
+
901
+ try:
902
+ assignment_id, picked_machine = dispatch_acceptance_mock(
903
+ repo,
904
+ tracking_issue,
905
+ cfg,
906
+ machine_override=machine,
907
+ path=route_path,
908
+ amend_briefing=amend_text,
909
+ )
910
+ except RuntimeError as exc:
911
+ click.echo(f"error: {exc}", err=True)
912
+ sys.exit(1)
913
+
914
+ verb = "amend" if amend_text is not None else "mock-author"
915
+ click.echo(f"Dispatched {verb} for #{tracking_issue} -> {picked_machine}")
916
+ click.echo(assignment_id)