code-coordinator 0.5.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
coord/acceptance.py ADDED
@@ -0,0 +1,468 @@
1
+ """Core ``coord acceptance`` orchestration (#944, docs/ORACLE_LOOP.md).
2
+
3
+ Pure/testable logic shared by the ``coord acceptance run`` / ``record`` CLI
4
+ commands in ``coord/commands/acceptance.py``: manifest loading (test-id ->
5
+ issue slice mapping) and building the structured verdict payload from a
6
+ driver's parsed test results. Kept separate from the CLI so it can be unit
7
+ tested without Click's invocation machinery, mirroring the
8
+ ``test_orchestrator.py`` / ``commands/test_gate.py`` split.
9
+
10
+ Layout this module expects (docs/ORACLE_LOOP.md "Layout"):
11
+
12
+ tests/acceptance/ms-NN/
13
+ contract.md # black-box surface (not read by this module)
14
+ mocks/ # viewable mocks == assertion fixtures
15
+ <suite files> # SEALED to the worker
16
+ manifest.(yml|json) # test-id -> issue-slice mapping
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any, Callable
25
+
26
+ import yaml
27
+
28
+ from coord.config import Config
29
+ from coord.models import Machine, Repo
30
+
31
+ ACCEPTANCE_DIRNAME = "tests/acceptance"
32
+
33
+
34
+ def ms_dirname(milestone_number: int) -> str:
35
+ """The ``ms-NN`` directory name for *milestone_number* (docs/ORACLE_LOOP.md
36
+ "Layout"). Single source of truth for the naming convention so Gate A
37
+ (#930, ``coord acceptance mock``) and the manifest reader agree."""
38
+ return f"ms-{milestone_number}"
39
+
40
+
41
+ def gate_a_contract_path(milestone_number: int) -> str:
42
+ """Repo-relative path to *milestone_number*'s Gate A contract
43
+ (docs/ORACLE_LOOP.md "Layout": ``tests/acceptance/ms-NN/contract.md``).
44
+
45
+ Used both by ``coord acceptance mock`` (#930, what it writes) and
46
+ ``coord.milestone_dispatch.gate_a_status`` (what it checks for before
47
+ letting the milestone's issues dispatch).
48
+ """
49
+ return f"{ACCEPTANCE_DIRNAME}/{ms_dirname(milestone_number)}/contract.md"
50
+
51
+
52
+ def _mocks_dir(milestone_number: int) -> str:
53
+ return f"{ACCEPTANCE_DIRNAME}/{ms_dirname(milestone_number)}/mocks"
54
+
55
+
56
+ # Mock-fixture file extension -> the driver ``kind`` it implies (the SAME
57
+ # rule each ``AcceptanceDriverConfig.mock`` glob already encodes in
58
+ # coordinator.yml / docs/ORACLE_LOOP.md: ``"*.screen"`` for ``tui-tuidriver``,
59
+ # ``"*.out"`` for ``cli-pytest``, ``"*.html"`` for ``web-playwright`` (#1542
60
+ # — hand-authored, self-contained wireframes; see
61
+ # ``coord.agent.MOCK_AUTHOR_SYSTEM_PROMPT`` for the authoring rules and
62
+ # ``tests/acceptance/ms-example/mocks/`` for a worked example). Single
63
+ # source of truth for the mock-kind -> ``--for-path`` derivation (#1453
64
+ # review) — do not re-derive this mapping a second time anywhere else.
65
+ MOCK_EXT_TO_DRIVER_KIND: dict[str, str] = {
66
+ ".screen": "tui-tuidriver",
67
+ ".out": "cli-pytest",
68
+ ".html": "web-playwright",
69
+ }
70
+
71
+
72
+ class ForPathResolutionError(Exception):
73
+ """A routed repo's ``--for-path`` could not be resolved unambiguously
74
+ from a milestone's Gate-A mocks. Message is operator-facing."""
75
+
76
+
77
+ # (repo_github, dir_path, branch) -> filenames (not full paths) directly
78
+ # under that directory, or () when it doesn't exist. Injected so tests never
79
+ # hit `gh` — mirrors ``coord.milestone_dispatch.GateAFileExists``.
80
+ MockLister = Callable[[str, str, str], "tuple[str, ...]"]
81
+
82
+
83
+ def _default_list_mock_dir(repo_github: str, path: str, branch: str) -> "tuple[str, ...]":
84
+ from coord import github_ops # noqa: PLC0415
85
+
86
+ try:
87
+ return tuple(github_ops.list_repo_dir(repo_github, path, branch=branch))
88
+ except RuntimeError:
89
+ return ()
90
+
91
+
92
+ def resolve_for_path(
93
+ config: Config,
94
+ repo_cfg: Repo,
95
+ milestone_number: int,
96
+ *,
97
+ list_mock_dir: MockLister | None = None,
98
+ ) -> str | None:
99
+ """Derive the ``--for-path`` glob a ROUTED repo's JIT acceptance-author
100
+ dispatch needs, from *milestone_number*'s Gate-A mock file kind.
101
+
102
+ SHARED helper (#1453 review finding 1, tracked for #1460's TUI-menu
103
+ equivalent too — do not duplicate this rule): the mock fixtures a
104
+ milestone's Gate-A contract ships under ``tests/acceptance/ms-NN/mocks/``
105
+ (already merged to the default branch by the time this is ever called —
106
+ :func:`coord.milestone_dispatch.gate_a_status` gates on exactly that)
107
+ have a file extension that implies exactly one driver ``kind``
108
+ (:data:`MOCK_EXT_TO_DRIVER_KIND`). Crossing that against
109
+ ``acceptance.drivers.<repo>.routes[].kind`` picks the one route whose
110
+ ``match`` glob is this milestone's ``--for-path``.
111
+
112
+ Returns:
113
+ - ``None`` when *repo_cfg* has no acceptance driver at all, or a FLAT
114
+ (unrouted) one — :meth:`coord.config.AcceptanceConfig.driver_for`
115
+ already resolves those with no path, so no ``--for-path`` is needed.
116
+ - the single matching route's ``match`` glob when resolution is
117
+ unambiguous.
118
+
119
+ Raises :class:`ForPathResolutionError` (operator-facing, mirrors
120
+ ``coord.test_author.dispatch_test_author``'s "no route matched" message)
121
+ when the repo IS routed but resolution is ambiguous — no mocks found,
122
+ more than one mock kind present, or zero/more-than-one route declares
123
+ the implied kind. Callers should surface this rather than guess.
124
+ """
125
+ entry = config.acceptance.drivers.get(repo_cfg.name)
126
+ if entry is None or not entry.routes:
127
+ return None
128
+
129
+ lister = list_mock_dir or _default_list_mock_dir
130
+ mocks_dir = _mocks_dir(milestone_number)
131
+ names = lister(repo_cfg.github, mocks_dir, repo_cfg.default_branch)
132
+
133
+ kinds = {
134
+ MOCK_EXT_TO_DRIVER_KIND[Path(name).suffix]
135
+ for name in names
136
+ if Path(name).suffix in MOCK_EXT_TO_DRIVER_KIND
137
+ }
138
+
139
+ def _refuse(reason: str) -> "ForPathResolutionError":
140
+ routes = ", ".join(f"{r.match!r} ({r.kind})" for r in entry.routes)
141
+ return ForPathResolutionError(
142
+ f"repo {repo_cfg.name!r} has a routed acceptance driver ({routes}) "
143
+ f"but --for-path could not be derived from {mocks_dir!r}'s mock "
144
+ f"kind: {reason}. Pass --no-acceptance to skip JIT authoring, or "
145
+ f"dispatch by hand: coord acceptance author {repo_cfg.name} "
146
+ "<tracking_issue> --issue <N> --for-path <glob>"
147
+ )
148
+
149
+ if not kinds:
150
+ raise _refuse(f"no recognized mock files found ({names!r})")
151
+ if len(kinds) > 1:
152
+ raise _refuse(f"mixed mock kinds found ({sorted(kinds)!r})")
153
+ kind = next(iter(kinds))
154
+
155
+ matches = [route.match for route in entry.routes if route.kind == kind]
156
+ if len(matches) != 1:
157
+ raise _refuse(
158
+ f"mock kind {kind!r} matches {len(matches)} routes, need exactly 1"
159
+ )
160
+ return matches[0]
161
+
162
+
163
+ class ManifestError(Exception):
164
+ """Raised when a manifest file exists but is malformed."""
165
+
166
+
167
+ def _manifest_paths(acceptance_root: Path) -> list[Path]:
168
+ """``ms-NN/manifest.(yml|yaml|json)`` paths under *acceptance_root*,
169
+ sorted for deterministic scan order. ``[]`` when the dir doesn't exist."""
170
+ if not acceptance_root.exists():
171
+ return []
172
+ return sorted(
173
+ p for p in acceptance_root.glob("*/manifest.*")
174
+ if p.suffix in (".yml", ".yaml", ".json")
175
+ )
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class ManifestData:
180
+ """One manifest file's parsed contents: the test-id -> issue-number
181
+ mapping, plus the #1138 issue-level ``exempt:`` list (issues in an
182
+ oracle-opted-in milestone that are deliberately validated by their own
183
+ unit tests instead of the sealed suite — e.g. the driver-building issue
184
+ itself, #1125 — declared here instead of living only as tribal knowledge
185
+ in an issue body).
186
+
187
+ #2063 adds the milestone-level ``gate_a:`` block: the declared,
188
+ reviewable opt-out from the Gate-A human sign-off gate, same posture as
189
+ ``exempt:`` above — a milestone whose surface genuinely needs no human
190
+ eye says so in the repo, in writing, rather than the gate quietly not
191
+ existing for everyone."""
192
+
193
+ tests: dict[str, int] = field(default_factory=dict)
194
+ exempt: frozenset[int] = field(default_factory=frozenset)
195
+ #: ``gate_a: {exempt: true, reason: "..."}`` — this milestone's contract
196
+ #: may be consumed without a recorded human verdict (#2063).
197
+ gate_a_exempt: bool = False
198
+ gate_a_exempt_reason: str = ""
199
+
200
+
201
+ def parse_manifest_text(text: str, *, source: str = "<manifest>") -> ManifestData:
202
+ """Pure parse of one manifest file's YAML/JSON text into
203
+ :class:`ManifestData`. Shared by the local-disk loader
204
+ (:func:`_parse_manifest_file`, used by ``coord acceptance run``/
205
+ ``record`` on a worker's own checkout) and the dispatch-time GitHub-fetch
206
+ reader (:func:`coord.milestone_dispatch.issue_oracle_ready`, #1138) so
207
+ both agree on the manifest schema.
208
+
209
+ Three on-disk shapes are accepted:
210
+
211
+ - ``tests: {<test-id>: <issue-number>, ...}`` — flat, one issue per test.
212
+ - ``issues: {<issue-number>: [<test-id>, ...], ...}`` — grouped by issue.
213
+ - ``exempt: [<issue-number>, ...]`` — issues exempted from the #1138
214
+ issue-level oracle gate (no slice required before Work dispatch).
215
+
216
+ Plus one milestone-level block:
217
+
218
+ - ``gate_a: {exempt: true, reason: "..."}`` — this milestone's contract
219
+ may be consumed without a recorded human sign-off (#2063). ``gate_a:
220
+ true`` is accepted as shorthand. Anything else (including a missing
221
+ key) leaves the gate on.
222
+ """
223
+ try:
224
+ raw = yaml.safe_load(text)
225
+ except yaml.YAMLError as e:
226
+ raise ManifestError(f"failed to parse manifest {source}: {e}") from e
227
+ if raw is None:
228
+ return ManifestData()
229
+ if not isinstance(raw, dict):
230
+ raise ManifestError(f"manifest {source} must be a mapping")
231
+
232
+ mapping: dict[str, int] = {}
233
+ tests_raw = raw.get("tests")
234
+ if isinstance(tests_raw, dict):
235
+ for test_id, issue in tests_raw.items():
236
+ mapping[str(test_id)] = int(issue)
237
+
238
+ issues_raw = raw.get("issues")
239
+ if isinstance(issues_raw, dict):
240
+ for issue, test_ids in issues_raw.items():
241
+ if not isinstance(test_ids, list):
242
+ continue
243
+ for test_id in test_ids:
244
+ mapping[str(test_id)] = int(issue)
245
+
246
+ exempt: frozenset[int] = frozenset()
247
+ exempt_raw = raw.get("exempt")
248
+ if isinstance(exempt_raw, list):
249
+ exempt = frozenset(int(x) for x in exempt_raw)
250
+
251
+ gate_a_exempt = False
252
+ gate_a_reason = ""
253
+ gate_a_raw = raw.get("gate_a")
254
+ if isinstance(gate_a_raw, dict):
255
+ gate_a_exempt = bool(gate_a_raw.get("exempt"))
256
+ gate_a_reason = str(gate_a_raw.get("reason") or "")
257
+ elif isinstance(gate_a_raw, bool):
258
+ gate_a_exempt = gate_a_raw
259
+
260
+ return ManifestData(
261
+ tests=mapping,
262
+ exempt=exempt,
263
+ gate_a_exempt=gate_a_exempt,
264
+ gate_a_exempt_reason=gate_a_reason,
265
+ )
266
+
267
+
268
+ def _parse_manifest_file(path: Path) -> dict[str, int]:
269
+ """Parse one manifest file into ``{test_id: issue_number}`` (the
270
+ ``exempt:`` list, if any, is dropped — callers that need it use
271
+ :func:`parse_manifest_text` directly). Two on-disk shapes are accepted:
272
+
273
+ - ``tests: {<test-id>: <issue-number>, ...}`` — flat, one issue per test.
274
+ - ``issues: {<issue-number>: [<test-id>, ...], ...}`` — grouped by issue.
275
+ """
276
+ try:
277
+ text = path.read_text()
278
+ except OSError as e:
279
+ raise ManifestError(f"failed to parse manifest {path}: {e}") from e
280
+ return parse_manifest_text(text, source=str(path)).tests
281
+
282
+
283
+ def load_manifest(acceptance_root: Path) -> dict[str, int]:
284
+ """Merge every ``ms-NN/manifest.(yml|json)`` under *acceptance_root* into
285
+ one ``{test_id: issue_number}`` mapping.
286
+
287
+ Returns ``{}`` when *acceptance_root* doesn't exist or has no manifest
288
+ files yet (the suite hasn't been authored — sibling issue #931). Later
289
+ manifests win on a test-id collision (last one scanned, sorted by path
290
+ for determinism) rather than raising, since two milestones legitimately
291
+ sharing a test id is an authoring bug, not something this reader should
292
+ crash the whole run over.
293
+ """
294
+ mapping: dict[str, int] = {}
295
+ for path in _manifest_paths(acceptance_root):
296
+ mapping.update(_parse_manifest_file(path))
297
+ return mapping
298
+
299
+
300
+ def ms_dir_for_issue(acceptance_root: Path, issue_number: int) -> str | None:
301
+ """The ``ms-NN`` directory name (under *acceptance_root*) whose manifest
302
+ covers *issue_number*, or ``None`` if no manifest maps any test to it yet
303
+ (the issue's slice hasn't been authored — #945 uses this to decide
304
+ whether there's a contract to point the worker at).
305
+
306
+ Unlike :func:`load_manifest`, this checks manifests **per file** rather
307
+ than merging first, since the whole point is recovering *which* ``ms-NN``
308
+ dir a given issue's tests live under.
309
+ """
310
+ for path in _manifest_paths(acceptance_root):
311
+ mapping = _parse_manifest_file(path)
312
+ if test_ids_for_issue(mapping, issue_number):
313
+ return path.parent.name
314
+ return None
315
+
316
+
317
+ def oracle_loop_contract_block(
318
+ acceptance_root: Path, repo_name: str, issue_number: int
319
+ ) -> str:
320
+ """The worker briefing contract (#945, docs/ORACLE_LOOP.md "The worker
321
+ briefing contract") prepended to the TOP of a Work briefing when
322
+ *issue_number* has a sealed acceptance slice authored for it under
323
+ *acceptance_root*.
324
+
325
+ Returns ``""`` when the issue has no authored slice yet (nothing to
326
+ point the worker at — Gate A/#931 hasn't run for it) or on any read
327
+ error. Fully fail-soft — mirrors ``coord.state.issue_context_block``
328
+ (#603): this runs on the dispatch hot path, so a manifest hiccup must
329
+ degrade to "no block" rather than break dispatch.
330
+ """
331
+ try:
332
+ ms_dir = ms_dir_for_issue(acceptance_root, issue_number)
333
+ except Exception: # noqa: BLE001 — never let a manifest read break dispatch
334
+ return ""
335
+ if ms_dir is None:
336
+ return ""
337
+
338
+ contract_path = f"{ACCEPTANCE_DIRNAME}/{ms_dir}/contract.md"
339
+ mocks_dir = f"{ACCEPTANCE_DIRNAME}/{ms_dir}/mocks"
340
+ return (
341
+ "## 🔒 Oracle-loop acceptance contract — READ THIS FIRST\n\n"
342
+ "This issue has a sealed acceptance slice authored for it. Treat "
343
+ f"`{contract_path}` (the black-box surface) — and, if present, "
344
+ f"the rendered mock(s) under `{mocks_dir}/` — as the spec — not "
345
+ "guesswork. For a web slice the mocks ARE part of the contract "
346
+ "(hand-authored HTML wireframes, one per screen state): the app "
347
+ "must satisfy the sealed assertions written against them, not the "
348
+ "other way around.\n\n"
349
+ f"- You **may not** edit `{ACCEPTANCE_DIRNAME}/**` (contract, "
350
+ "mocks, or the sealed suite). It is the sealed oracle, authored "
351
+ "independently of your work — touching it fails the gate.\n"
352
+ f"- Run `coord acceptance run --repo {repo_name} --issue "
353
+ f"{issue_number}` to check yourself; iterate in this warm session "
354
+ "until your slice is green, then release.\n"
355
+ "- Write your own unit / internal tests too — that is still your "
356
+ "job.\n"
357
+ "- If your slice won't converge — the failing set churns rather "
358
+ "than shrinks across 2 rounds — **stop grinding**: run "
359
+ f"`coord acceptance stall --repo {repo_name} --issue {issue_number} "
360
+ '--tried "..." --stuck "..."` (#846) so the coordinator sees it '
361
+ "immediately, in addition to a `STUCK:` line for the interactive "
362
+ "log.\n\n"
363
+ "---\n\n"
364
+ )
365
+
366
+
367
+ def test_ids_for_issue(manifest: dict[str, int], issue_number: int) -> set[str]:
368
+ """The set of test ids mapped to *issue_number* in *manifest*."""
369
+ return {test_id for test_id, issue in manifest.items() if issue == issue_number}
370
+
371
+
372
+ def build_verdict(
373
+ tests: list[dict],
374
+ *,
375
+ scope: str,
376
+ issue_number: int | None = None,
377
+ ) -> dict[str, Any]:
378
+ """Assemble the structured pass/fail payload ``coord acceptance run``
379
+ prints and ``record`` persists a summary of.
380
+
381
+ *tests* is the (already filtered, when scoped to one issue) list of
382
+ ``{"id", "status", "message"}`` dicts from a driver. Sealed: this only
383
+ ever carries verdicts (id/status/message), never test source.
384
+ """
385
+ passed = sum(1 for t in tests if t.get("status") == "pass")
386
+ failed = sum(1 for t in tests if t.get("status") == "fail")
387
+ skipped = sum(1 for t in tests if t.get("status") == "skip")
388
+ payload: dict[str, Any] = {
389
+ "scope": scope,
390
+ "total": len(tests),
391
+ "passed": passed,
392
+ "failed": failed,
393
+ "skipped": skipped,
394
+ "green": failed == 0 and len(tests) > 0,
395
+ "tests": tests,
396
+ }
397
+ if issue_number is not None:
398
+ payload["issue"] = issue_number
399
+ return payload
400
+
401
+
402
+ def failure_summary(verdict: dict[str, Any], *, limit: int = 5) -> str:
403
+ """One-line-per-failure summary text for a verdict payload (used as the
404
+ Acceptance-gate reason string and the #603 durable-context note)."""
405
+ failing = [t for t in verdict.get("tests", []) if t.get("status") == "fail"]
406
+ if not failing:
407
+ return ""
408
+ lines = [f"{t['id']}: {t.get('message') or 'failed'}" for t in failing[:limit]]
409
+ if len(failing) > limit:
410
+ lines.append(f"... and {len(failing) - limit} more")
411
+ return "\n".join(lines)
412
+
413
+
414
+ def dump_manifest_error_hint(acceptance_root: Path) -> str:
415
+ """Human-facing hint for "no manifest found" — points at the authoring
416
+ step (#931) rather than leaving the operator guessing."""
417
+ return (
418
+ f"no acceptance manifest found under {acceptance_root} — the sealed "
419
+ "suite has not been authored yet for this repo (see docs/ORACLE_LOOP.md "
420
+ "/ #931)."
421
+ )
422
+
423
+
424
+ def acceptance_capability_gap(
425
+ capability: str, repo_name: str, config: Config,
426
+ ) -> Machine | None:
427
+ """Detect a capability-matched-routing gap for an acceptance driver run
428
+ (#966, deferred from #932/#944).
429
+
430
+ ``coord acceptance run --all`` (Gate C) and ``coord acceptance record``
431
+ always execute the driver's ``run`` command on whatever host invoked
432
+ them — there is no remote-exec plumbing to actually route the run
433
+ elsewhere yet (that's the "new plumbing, not a copy-paste" part #966
434
+ defers until a driver with a real capability mismatch exists). What this
435
+ function *can* do cheaply — mirroring ``coord.smoke.pick_smoke_machine``'s
436
+ candidate filter, minus the async/busy-machine bits that don't apply to
437
+ a synchronous command — is detect when it's about to run on the *wrong*
438
+ hardware, so the caller can fail loudly instead of silently.
439
+
440
+ Returns the first other configured machine that has *repo_name* and
441
+ *capability*, when:
442
+ - *capability* is set (drivers without one, e.g. today's only real
443
+ driver's implicit local-only assumption, are never gapped), AND
444
+ - this host is a recognized machine in ``coordinator.yml`` that does
445
+ NOT have *capability* (an unrecognized host is given the benefit of
446
+ the doubt — it might be a dev machine outside the fleet that happens
447
+ to have everything installed), AND
448
+ - some other configured machine actually has it (nothing to route to
449
+ otherwise, so failing wouldn't be actionable).
450
+
451
+ Returns ``None`` in every other case — i.e. "no known gap, proceed."
452
+ """
453
+ if not capability:
454
+ return None
455
+
456
+ from coord.test_orchestrator import local_machine # noqa: PLC0415 — avoid import cycle
457
+
458
+ here = local_machine(config)
459
+ if here is None or capability in here.capabilities:
460
+ return None
461
+
462
+ candidates = [
463
+ m for m in config.machines
464
+ if m.can_work_on(repo_name) and capability in m.capabilities
465
+ ]
466
+ if not candidates:
467
+ return None
468
+ return candidates[0]