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/config.py ADDED
@@ -0,0 +1,3032 @@
1
+ """Parse and validate coordinator.yml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass, field, fields
9
+ from datetime import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
13
+
14
+ import yaml
15
+
16
+ from coord.liveness_auditor import (
17
+ DEFAULT_DEBOUNCE_SECONDS as DEFAULT_LIVENESS_DEBOUNCE_SECONDS,
18
+ DEFAULT_MODEL as DEFAULT_LIVENESS_MODEL,
19
+ DEFAULT_STRIKES as DEFAULT_LIVENESS_STRIKES,
20
+ DEFAULT_TIMEOUT_SECONDS as DEFAULT_LIVENESS_TIMEOUT_SECONDS,
21
+ )
22
+ from coord.models import Machine, QuietHours, Repo, WorkerPermissionsConfig
23
+ from coord.platform_paths import default_coord_dir
24
+
25
+
26
+ DEFAULT_CONFIG_PATH = Path("coordinator.yml")
27
+
28
+ # Canonical config home — works on a machine that has no repo checkout, mirroring
29
+ # where ``~/.coord/coord.db`` and ``~/.coord/client.toml`` already live. This is
30
+ # the recommended location; ``./coordinator.yml`` stays a development fallback.
31
+ USER_CONFIG_PATH = default_coord_dir() / "coordinator.yml"
32
+
33
+
34
+ def resolve_config_path() -> Path:
35
+ """Resolve which ``coordinator.yml`` to load when no explicit path is given.
36
+
37
+ Search order (first existing file wins):
38
+
39
+ 1. ``$COORD_CONFIG`` (if set) — explicit override.
40
+ 2. ``~/.coord/coordinator.yml`` — the canonical home (no repo checkout needed).
41
+ 3. ``./coordinator.yml`` — CWD, for development / the repo checkout.
42
+
43
+ When none exist the canonical home path is returned so the "not found" error
44
+ points operators at the recommended location rather than at the CWD.
45
+ """
46
+ env = os.environ.get("COORD_CONFIG")
47
+ if env:
48
+ return Path(env).expanduser()
49
+ for candidate in (USER_CONFIG_PATH, DEFAULT_CONFIG_PATH):
50
+ if candidate.exists():
51
+ return candidate
52
+ return USER_CONFIG_PATH
53
+
54
+ # Safety-by-default: repos without explicit worker_permissions get this deny-list.
55
+ DEFAULT_DENY_COMMANDS: list[str] = [
56
+ "Bash(gh *)",
57
+ "Bash(git push --force *)",
58
+ "Bash(git push -f *)",
59
+ "Bash(git reset --hard *)",
60
+ "Bash(git branch -D *)",
61
+ "Bash(git checkout -- .)",
62
+ "Bash(git clean -f *)",
63
+ "Bash(rm -rf *)",
64
+ ]
65
+
66
+
67
+ class ConfigError(Exception):
68
+ """Raised when coordinator.yml is missing, malformed, or fails validation."""
69
+
70
+
71
+ @dataclass
72
+ class HooksConfig:
73
+ on_round_complete: list[str] = field(default_factory=list)
74
+ on_session_end: list[str] = field(default_factory=list)
75
+
76
+
77
+ @dataclass
78
+ class ReviewsConfig:
79
+ """Adversarial code review settings.
80
+
81
+ `enabled=True` by default. When enabled, `coord pr` auto-dispatches an
82
+ adversarial review to a different machine after the PR worker is sent.
83
+ Completion of a "work" assignment via reconciliation also triggers review
84
+ dispatch automatically (see coord/review.py). Set `enabled: false` in
85
+ coordinator.yml to opt out.
86
+ """
87
+
88
+ enabled: bool = True
89
+ auto_dispatch: bool = True
90
+ require_approval: bool = False
91
+ # #1811: optional provider override for the REVIEW dispatch, independent
92
+ # of the repo's own worker provider (`Repo.provider`). Threaded as
93
+ # `spec_provider` at both `guard_unattended_dispatch` call sites in
94
+ # coord/review.py — same precedence seam `resolve_provider_name` already
95
+ # implements (spec > repo > providers.default), just given a review-only
96
+ # entry point. `None` (the default) inherits `repo.provider` exactly as
97
+ # before this field existed — no existing deployment's behavior changes.
98
+ # Without this, the only way to review a repo pinned to a second backend
99
+ # (e.g. `provider: opencode`) is to let the review silently inherit that
100
+ # same backend — sharing the worker's model family removes the "zero
101
+ # shared context" independence adversarial review depends on. Validated
102
+ # against `providers.definitions` at parse time in `_parse_reviews` (an
103
+ # unknown name is a config error, not a dispatch-time surprise); the
104
+ # `human_attended_only` TOS gate (#437) still applies to whatever this
105
+ # resolves to, same as `repo.provider`.
106
+ provider: str | None = None
107
+ reviewer_prompt: str = ""
108
+ checklist: list[str] = field(default_factory=lambda: [
109
+ "Check for platform-specific code in shared/cross-platform paths",
110
+ ])
111
+ repo_overrides: dict[str, list[str]] = field(default_factory=dict)
112
+ # Flood guard (incident 2026-06-08): bound *bulk* review dispatch so a
113
+ # backlog "unmasking" (e.g. removing a gate that had been suppressing
114
+ # reviews) can't fire hundreds of metered `claude -p` reviews in one pass.
115
+ # See coord.review.dispatch_pending_reviews.
116
+ max_auto_dispatch_per_pass: int = 5 # cap reviews dispatched per reconcile/notify pass (0 = unbounded)
117
+ flood_threshold: int = 12 # if more rows than this are pending review in one pass, refuse all (0 = no surge gate)
118
+ allow_review_flood: bool = False # override the surge gate (or set env COORD_ALLOW_REVIEW_FLOOD=1)
119
+ # #1488: sanity bound (additions+deletions) for `coord review-reaffirm` —
120
+ # the audited escape hatch that re-points a stale-but-content-changed
121
+ # approval's `review_head_sha` to the branch's current head instead of
122
+ # requiring a full re-review. A mechanical conflict-resolution delta is
123
+ # tens of lines; anything past this is refused outright (no override
124
+ # flag) so the command can never be used to wave through a genuine
125
+ # rewrite. 0 disables the bound (not recommended).
126
+ reaffirm_max_diff_lines: int = 300
127
+
128
+
129
+ @dataclass
130
+ class ConcurrencyConfig:
131
+ max_workers: int = 2
132
+ stagger_seconds: float = 30.0
133
+ backoff_base: float = 60.0
134
+ max_retries: int = 3
135
+ auto_reassign: bool = False
136
+ stale_threshold: int = 3
137
+ # Spawn `claude -p` through a transient `bash -c 'exec ...'` parent so the
138
+ # immediate parent of claude is a short-lived shell. This is the upstream
139
+ # headline fix for the daemon-spawn freeze (anthropics/claude-code#56268).
140
+ bash_wrap_spawn: bool = True
141
+ # First-output (TTFT) watchdog: if a worker produces zero output within
142
+ # this many seconds, kill its process group and fail the assignment so the
143
+ # auto_reassign path re-dispatches it. 0 disables the watchdog. This only
144
+ # catches truly silent hangs — a rate-limited worker still emits output and
145
+ # therefore passes the check.
146
+ first_output_timeout: float = 600.0
147
+ # Remote interactive-session staleness timeout (#588). After a remote
148
+ # ``claude-pty`` assignment has been running for longer than this many
149
+ # hours, each reconcile pass probes the remote tmux session via SSH. If
150
+ # the session is dead (tmux has-session exits 1) the coordinator calls
151
+ # ``finalize_remote_interactive_exit`` to push any commits and release the
152
+ # machine slot. If SSH is unreachable, a warning is emitted instead.
153
+ # Default is 12 hours — generous enough that a genuinely long session is
154
+ # never interrupted, but tight enough to catch orphaned rows from crashed
155
+ # sessions overnight. Set to 0 to disable the sweep entirely.
156
+ interactive_session_timeout_hours: float = 12.0
157
+
158
+
159
+ @dataclass
160
+ class SmokeRule:
161
+ """When a worker's diff touches any of `files`, the smoke machine must
162
+ have all capabilities in `requires`.
163
+
164
+ `files` patterns match by prefix against the relative paths returned by
165
+ `gh pr view --json files`. A trailing `/` makes the prefix explicit; bare
166
+ paths match if the touched path starts with the rule path (so `src/gtk`
167
+ catches `src/gtk/foo.c` and `src/gtk_helpers.c`). Use `src/gtk/` to scope
168
+ strictly to the directory.
169
+ """
170
+
171
+ files: list[str] = field(default_factory=list)
172
+ requires: list[str] = field(default_factory=list)
173
+
174
+
175
+ @dataclass
176
+ class SmokeTestsConfig:
177
+ """Smoke-test orchestration. Off by default — opt-in per project.
178
+
179
+ `default_command` is the shell command the smoke agent runs (e.g.
180
+ `make smoke` or `pytest tests/smoke`). Per-repo overrides flow through
181
+ `Repo.test_command` already; this is the fallback when none is set.
182
+ """
183
+
184
+ auto_queue: bool = False
185
+ default_command: str | None = None
186
+ timeout_seconds: int = 600
187
+ capability_rules: list[SmokeRule] = field(default_factory=list)
188
+
189
+
190
+ @dataclass
191
+ class AcceptanceDriverConfig:
192
+ """One entry under ``acceptance.drivers.<repo_name>`` in coordinator.yml
193
+ (#944, docs/ORACLE_LOOP.md), OR one entry in that repo's ``routes:`` list
194
+ (#1125, in-repo path routing — see :class:`AcceptanceConfig.driver_for`).
195
+
196
+ ``kind`` selects the framework-specific adapter that knows how to launch,
197
+ drive, and parse a repo's sealed acceptance suite (``tui-tuidriver`` and
198
+ ``cli-pytest`` are implemented; other kinds are declared here but
199
+ rejected at run time by :mod:`coord.acceptance_drivers` until their
200
+ issues land). ``run`` is the shell command that executes the suite and
201
+ must print structured (JSON) verdicts to stdout — it may reference the
202
+ ``{ms}`` template (substituted with the ``ms-NN`` milestone dirname by
203
+ :func:`coord.acceptance_drivers.render_run_command`) to point at a
204
+ milestone-scoped suite dir. ``mock`` is a glob (relative to the
205
+ acceptance dir) for the viewable mock/assertion fixtures — ``*.screen``
206
+ for ``tui-tuidriver``, ``*.out`` (expected CLI stdout) for
207
+ ``cli-pytest`` — informational today, consumed by the future mock-author
208
+ (#930). ``capability`` is the machine capability required to run this
209
+ driver, intended to be routed the same way ``smoke_tests.capability_rules``
210
+ routes smoke tests.
211
+
212
+ ``setup`` (#1733) is an optional shell command run once, before ``run``,
213
+ to provision whatever a driver needs that a bare ``git checkout``
214
+ doesn't provide — e.g. ``npm ci`` for ``web-playwright``. It exists
215
+ because ``coord acceptance record``'s whole design is a throwaway ``git
216
+ worktree add --detach`` the worker never touches: that worktree has no
217
+ ``node_modules`` (gitignored, never checked out) and no way to get one
218
+ short of an explicit install step, so a JS driver's ``run`` failed with
219
+ a bare ``exit 127`` (playwright not found) before ever producing a
220
+ parsed verdict. ``tui-tuidriver`` (cargo fetches its own deps) and
221
+ ``cli-pytest`` (runs against the ambient env) happen to self-provision,
222
+ which is exactly why this went unnoticed until the first JS driver ran.
223
+ Left empty (the default), no provisioning step runs — unchanged
224
+ behaviour for every driver that doesn't need one. A non-zero exit from
225
+ ``setup`` is reported as a distinct "provisioning failed" error rather
226
+ than being folded into "tests failed"/"wrote no report" (see
227
+ :func:`coord.acceptance_drivers.run_driver`).
228
+
229
+ ``entrypoint`` (#1552) is the repo-root-relative file this driver's
230
+ ``run`` command links its slices through — the sealed oracle's *crate
231
+ root*, for a driver whose framework discovers tests via an entry point
232
+ rather than by walking a directory. ``tui-tuidriver``'s
233
+ ``cargo test --test acceptance`` cannot see
234
+ ``tests/acceptance/ms-NN/slice.rs`` at all until
235
+ ``tui/tests/acceptance.rs`` ``include!``s it, so that file is part of
236
+ the oracle and must be declared here: it is folded into the sealed set
237
+ (:meth:`AcceptanceConfig.sealed_paths`) so a ``test-author`` registering
238
+ its slice there is expected rather than a scope violation, and a
239
+ ``type="work"`` worker touching it still trips oracle tamper. Leave it
240
+ empty for a directory-discovered suite — ``cli-pytest``'s
241
+ ``pytest tests/acceptance/{ms}`` legitimately has no entry point, which
242
+ is exactly why #1175's blanket refusal only ever broke the Rust route.
243
+
244
+ ``match`` and ``routes`` implement #1125's in-repo path routing: a repo
245
+ entry with a non-empty ``routes`` list is a *router* — its own
246
+ ``kind``/``run``/``mock``/``capability``/``setup``/``entrypoint`` are
247
+ unused and each element of ``routes`` is itself an
248
+ ``AcceptanceDriverConfig`` with ``match`` set (a repo-root-relative
249
+ glob, e.g. ``"coord/**"``). A route entry's own ``routes`` is always
250
+ empty — nesting one level is the whole feature, not a recursive router.
251
+ See :meth:`AcceptanceConfig.driver_for` for the resolution rule.
252
+
253
+ ``capability`` IS consulted (#966): both ``coord acceptance run --all``
254
+ and ``coord acceptance record`` preflight-check it against the invoking
255
+ host via :func:`coord.acceptance.acceptance_capability_gap` and refuse
256
+ loudly, naming a capable machine, rather than silently running on
257
+ hardware that may not support the driver. #966 deliberately stopped
258
+ there rather than building actual remote-exec routing (the way ``coord
259
+ test``'s ``pick_smoke_machine``/``match_rules`` route smoke runs to
260
+ capable hardware) — that's real new plumbing, unjustified until a driver
261
+ with an *unroutable* capability mismatch actually exists; "fail loud
262
+ instead of silently running wrong" was enough to unblock #944's only
263
+ driver at the time. A daemon host must still satisfy every declared
264
+ driver's capability itself, since ``record`` always executes wherever
265
+ the daemon is (see ``coord.commands.acceptance._acceptance_record_via_daemon``)
266
+ — that is a real, operator-facing constraint on which machine can be the
267
+ daemon, not something this preflight check can route around.
268
+ """
269
+
270
+ kind: str = ""
271
+ run: str = ""
272
+ mock: str = ""
273
+ capability: str = ""
274
+ setup: str = ""
275
+ entrypoint: str = ""
276
+ match: str = ""
277
+ routes: list["AcceptanceDriverConfig"] = field(default_factory=list)
278
+
279
+
280
+ # #944 sealing v1: the sealed acceptance tree, relative to the repo root.
281
+ # Kept here (rather than imported from `coord.acceptance`) so config parsing
282
+ # stays dependency-free; `coord.acceptance.ACCEPTANCE_DIRNAME` is the same
283
+ # directory without the trailing slash.
284
+ SEALED_ACCEPTANCE_DIR = "tests/acceptance/"
285
+
286
+
287
+ @dataclass
288
+ class AcceptanceConfig:
289
+ """``acceptance.drivers`` — repo name -> :class:`AcceptanceDriverConfig`."""
290
+
291
+ drivers: dict[str, AcceptanceDriverConfig] = field(default_factory=dict)
292
+
293
+ def entrypoints(self, repo_name: str) -> list[str]:
294
+ """Every ``entrypoint:`` declared by *repo_name*'s acceptance driver
295
+ (#1552), deduped, declaration order preserved.
296
+
297
+ Path-independent by design, exactly like :meth:`has_driver` and for
298
+ the same reason: the callers (sealing, the reviewer's scope rule,
299
+ ``dispatch``'s forbid list) are deciding what the *whole repo's*
300
+ oracle covers, not which single route a given file resolves to. A
301
+ routed repo contributes one entry per route that declares one; a
302
+ flat repo contributes at most its own. Repos with no driver — and
303
+ drivers whose suite is directory-discovered (``cli-pytest``) —
304
+ return ``[]``.
305
+ """
306
+ entry = self.drivers.get(repo_name)
307
+ if entry is None:
308
+ return []
309
+ out: list[str] = []
310
+ for cfg in [entry, *entry.routes]:
311
+ ep = cfg.entrypoint.strip()
312
+ if ep and ep not in out:
313
+ out.append(ep)
314
+ return out
315
+
316
+ def sealed_paths(self, repo_name: str) -> list[str]:
317
+ """The full sealed-oracle path set for *repo_name* (#944 sealing v1,
318
+ #1552) — ``[]`` when the repo has no acceptance driver at all.
319
+
320
+ Two kinds of entry, distinguished by the trailing slash:
321
+
322
+ - ``"tests/acceptance/"`` — a directory prefix; everything under it
323
+ is sealed.
324
+ - each declared driver ``entrypoint`` (e.g.
325
+ ``"tui/tests/acceptance.rs"``) — an exact file.
326
+
327
+ #1552: before this was derived, the set was a single hardcoded
328
+ literal in ``coord.review``, which happened to fit ``cli-pytest``
329
+ (pytest walks the directory) and was structurally unsatisfiable for
330
+ ``tui-tuidriver`` (cargo needs a crate root that ``include!``s each
331
+ slice). A ``test-author`` on the Rust route could either wire its
332
+ slice in and trip a mandatory ``request-changes``, or leave it
333
+ unwired and ship 476 lines of dead code. Deriving the set from the
334
+ driver definition lets each route declare its own entry point
335
+ instead.
336
+ """
337
+ if not self.has_driver(repo_name):
338
+ return []
339
+ return [SEALED_ACCEPTANCE_DIR, *self.entrypoints(repo_name)]
340
+
341
+ def driver_for(
342
+ self, repo_name: str, path: str | None = None,
343
+ ) -> AcceptanceDriverConfig | None:
344
+ """Resolve *repo_name*'s acceptance driver, optionally routed by
345
+ *path* (#1125, repo-root-relative — e.g. ``"coord/acceptance.py"``).
346
+
347
+ - Unknown repo -> ``None``.
348
+ - Repo entry has no ``routes`` (today's flat single-driver form,
349
+ back-compat) -> the entry itself, regardless of *path*.
350
+ - Repo entry has ``routes`` -> the **first** route whose ``match``
351
+ glob matches *path* (``fnmatch`` semantics, e.g. ``"coord/**"``
352
+ matches ``"coord/acceptance.py"``); first-match wins when more
353
+ than one route's glob matches. ``path=None`` against a routed
354
+ entry can't select a route, so it returns ``None`` rather than
355
+ guessing one — callers that know they're driving a specific file
356
+ (or an issue's manifest-mapped path) must pass it.
357
+
358
+ Resolution rule when a milestone/issue's slice spans more than one
359
+ route (#1125 review finding 4): this method makes no attempt to
360
+ detect or merge across routes for a single call — it resolves
361
+ exactly one *path* to exactly one route (or ``None``). A caller
362
+ whose work spans multiple routes (e.g. a full-stack issue touching
363
+ both ``coord/**`` and ``tui/**``) must pick ONE representative path
364
+ for the invocation (or invoke once per route) rather than expect
365
+ this method to fan out; callers driving a whole repo/milestone with
366
+ no single path in hand (Gate A, sealing, briefing-injection) should
367
+ use :meth:`has_driver` instead, which is path-independent by design.
368
+ """
369
+ entry = self.drivers.get(repo_name)
370
+ if entry is None:
371
+ return None
372
+ if not entry.routes:
373
+ return entry
374
+ if path is None:
375
+ return None
376
+ for route in entry.routes:
377
+ if fnmatch.fnmatch(path, route.match):
378
+ return route
379
+ return None
380
+
381
+ def has_driver(self, repo_name: str) -> bool:
382
+ """Path-independent "does this repo participate in the oracle loop
383
+ at all" predicate (#1125 review finding 1).
384
+
385
+ True when *repo_name* has ANY acceptance driver configured — flat
386
+ or routed — regardless of which route a given path would resolve
387
+ to. Use this for existence-only checks that must not silently flip
388
+ the moment a repo adopts ``routes:`` — Gate A
389
+ (``coord.milestone_dispatch.gate_a_status``), the ``tests/acceptance/``
390
+ sealing/forbid list (``coord.dispatch.dispatch``), and the
391
+ oracle-loop briefing-contract injection (``coord.dispatch.dispatch``)
392
+ all only need "yes/no", never a concrete driver to run — use
393
+ :meth:`driver_for` (with a *path*) for that.
394
+ """
395
+ return repo_name in self.drivers
396
+
397
+
398
+ # #1430: plan-worker ESTIMATE -> escalation rung (see ModelsConfig.model_for_estimate).
399
+ _ESTIMATE_RUNG: dict[str, int] = {"trivial": 0, "small": 0, "medium": 1, "large": 2}
400
+
401
+
402
+ @dataclass
403
+ class ModelsConfig:
404
+ """Model tier selection and escalation ladder for workers.
405
+
406
+ `default` is the model passed to ``claude -p`` when an assignment doesn't
407
+ specify one. `escalation` is an ordered list of model aliases (low →
408
+ high); when a worker fails or gets stuck, the coordinator escalates to
409
+ the next entry via `next_model`. `labels` is a per-issue-label override
410
+ (e.g. ``documentation: haiku``) resolved via :meth:`model_for_labels` —
411
+ consulted by every ``type="work"`` dispatch site (``coord plan`` /
412
+ ``approve``, ``coord assign``, ``coord milestone dispatch``); plan-stage
413
+ and review-stage dispatches deliberately stay on ``default`` (#1430).
414
+
415
+ `versions` pins an alias to an exact model id, e.g.
416
+ ``{sonnet: claude-sonnet-4-6, opus: claude-opus-4-7}``. When set, the
417
+ coordinator translates the alias to the exact id before passing it to
418
+ ``claude -p --model`` on the worker. Aliases not present in the map
419
+ pass through unchanged, so ``claude -p`` falls back to its CLI default
420
+ (which today is whatever the installed claude-cli treats as latest).
421
+ """
422
+
423
+ default: str = "sonnet"
424
+ escalation: list[str] = field(
425
+ default_factory=lambda: ["haiku", "sonnet", "opus"]
426
+ )
427
+ labels: dict[str, str] = field(default_factory=dict)
428
+ versions: dict[str, str] = field(default_factory=dict)
429
+
430
+ def next_model(self, current: str) -> str:
431
+ """Return the next model in the escalation ladder.
432
+
433
+ If *current* is already at the top of the ladder, or isn't on the
434
+ ladder at all, return *current* unchanged.
435
+ """
436
+ try:
437
+ idx = self.escalation.index(current)
438
+ except ValueError:
439
+ return current
440
+ if idx + 1 < len(self.escalation):
441
+ return self.escalation[idx + 1]
442
+ return current
443
+
444
+ def resolve(self, alias: str | None) -> str | None:
445
+ """Resolve an alias to its pinned exact model id, if configured.
446
+
447
+ Returns *alias* unchanged when no mapping exists, and ``None`` when
448
+ *alias* is ``None`` (preserves the "omit --model" code path).
449
+ """
450
+ if alias is None:
451
+ return None
452
+ return self.versions.get(alias, alias)
453
+
454
+ def model_for_labels(self, issue_labels: list[str]) -> str | None:
455
+ """Resolve an issue's GitHub labels to a model alias via ``labels``.
456
+
457
+ #1430: ``labels`` used to be validated at parse time and read by
458
+ nothing — every dispatch ran ``default`` regardless of the issue's
459
+ tier/category label. This is the resolver every dispatch site now
460
+ calls before falling back to ``default`` itself.
461
+
462
+ Precedence when an issue carries several configured labels (e.g.
463
+ both ``bug`` and ``tier:large``) — see
464
+ :meth:`model_for_labels_with_reason` for the full rule (#1633).
465
+
466
+ Returns ``None`` (never ``default``) when no configured label is
467
+ present on the issue, or ``labels`` itself is empty — mirroring the
468
+ None-passthrough style of :meth:`resolve`. Callers are expected to
469
+ fall back to ``default`` themselves, e.g.::
470
+
471
+ model = override or config.models.model_for_labels(issue_labels) or config.models.default
472
+ """
473
+ return self.model_for_labels_with_reason(issue_labels)[0]
474
+
475
+ def model_for_labels_with_reason(
476
+ self, issue_labels: list[str]
477
+ ) -> tuple[str | None, str | None, list[str]]:
478
+ """Like :meth:`model_for_labels`, but also returns the label that
479
+ matched and any configured labels it shadowed.
480
+
481
+ Returns ``(model, matched_label, shadowed_labels)``, or
482
+ ``(None, None, [])`` under the same conditions
483
+ :meth:`model_for_labels` returns ``None``.
484
+
485
+ #1633: precedence used to be decided by *issue-label order* — the
486
+ order GitHub happens to return the issue's own labels in, which
487
+ nothing in this repo controls. That made ``tier:small``/
488
+ ``tier:large`` no-ops on any issue that also carried a type label
489
+ (``bug``/``enhancement``/...), and let the same issue re-route
490
+ just by having a label removed and re-added. Precedence is now
491
+ deterministic and config-driven instead:
492
+
493
+ 1. ``tier:*`` entries are checked first — they are documented as
494
+ size-tier *overrides* over the type-label entries.
495
+ 2. All other entries are checked next.
496
+
497
+ Within each group, ties are broken by ``labels``'s own iteration
498
+ order (insertion order from ``coordinator.yml``), not the issue's
499
+ label order — so the same issue + config always resolves to the
500
+ same model, regardless of what order GitHub reports the issue's
501
+ labels in.
502
+
503
+ #1454: a silent fall-through to ``default`` (stale/missing label)
504
+ looks identical to an intentional default from the CLI output
505
+ alone. Callers use the matched label to print *why* a model was
506
+ chosen, and *shadowed_labels* (every other configured label also
507
+ present on the issue, in resolution order) to print what lost —
508
+ see :func:`describe_model_choice`.
509
+ """
510
+ if not self.labels:
511
+ return None, None, []
512
+ present_in_config_order = [
513
+ label for label in self.labels if label in issue_labels
514
+ ]
515
+ if not present_in_config_order:
516
+ return None, None, []
517
+ tier_candidates = [
518
+ label for label in present_in_config_order if label.startswith("tier:")
519
+ ]
520
+ other_candidates = [
521
+ label for label in present_in_config_order if not label.startswith("tier:")
522
+ ]
523
+ ordered_candidates = tier_candidates + other_candidates
524
+ matched = ordered_candidates[0]
525
+ shadowed = ordered_candidates[1:]
526
+ return self.labels[matched], matched, shadowed
527
+
528
+ def model_for_estimate(self, estimate: str | None) -> str | None:
529
+ """Map a plan worker's ``ESTIMATE`` to a model alias via ``escalation``.
530
+
531
+ #1430: once a plan has run, its ``ESTIMATE`` (trivial | small |
532
+ medium | large — derived from actually reading the code) is a
533
+ better-informed signal than the label chosen at issue-creation time,
534
+ so ``approve_plan`` uses this to override the label-derived model
535
+ for the work assignment it dispatches.
536
+
537
+ ``trivial``/``small`` resolve to the lowest rung of ``escalation``,
538
+ ``medium`` to the middle, ``large`` to the top — clamped to
539
+ ``len(escalation) - 1`` so a short/custom ladder doesn't index out
540
+ of range. Returns ``None`` for an empty/unrecognised estimate or an
541
+ empty ``escalation`` list — callers fall back to the label-derived
542
+ or default model themselves.
543
+ """
544
+ if not estimate or not self.escalation:
545
+ return None
546
+ idx = _ESTIMATE_RUNG.get(estimate.strip().lower())
547
+ if idx is None:
548
+ return None
549
+ idx = min(idx, len(self.escalation) - 1)
550
+ return self.escalation[idx]
551
+
552
+
553
+ def describe_model_choice(
554
+ *,
555
+ resolved_model: str,
556
+ explicit_reason: str | None = None,
557
+ matched_label: str | None = None,
558
+ shadowed_labels: list[str] | None = None,
559
+ ) -> str:
560
+ """Format a one-line explanation of why *resolved_model* was chosen.
561
+
562
+ #1454: dispatch used to print just the bare model name, so a silent
563
+ mis-route to ``models.default`` (e.g. a tier label that hadn't been
564
+ picked up yet) read identically to an intentional default — the exact
565
+ ambiguity that made the stale-label-cache bug expensive to notice.
566
+
567
+ *explicit_reason*, when set, wins outright (e.g. ``"explicit --model"``
568
+ or ``"resolved at plan time"``) — the caller already knows the model
569
+ didn't come from a fresh label match. Otherwise *matched_label* (from
570
+ :meth:`ModelsConfig.model_for_labels_with_reason`) selects between the
571
+ "via label" and "default; no label match" phrasings.
572
+
573
+ #1633: when the issue carried more than one configured label,
574
+ *shadowed_labels* names the ones that lost, so a route that might look
575
+ surprising (e.g. ``tier:large`` winning over ``enhancement``) is
576
+ self-explaining at dispatch time instead of reading like the older,
577
+ order-dependent bug.
578
+ """
579
+ if explicit_reason:
580
+ return f"{resolved_model} ({explicit_reason})"
581
+ if matched_label:
582
+ if shadowed_labels:
583
+ shadowed_str = ", ".join(repr(label) for label in shadowed_labels)
584
+ return (
585
+ f"{resolved_model} (via label {matched_label!r}, "
586
+ f"shadowing {shadowed_str})"
587
+ )
588
+ return f"{resolved_model} (via label {matched_label!r})"
589
+ return f"{resolved_model} (default; no label match)"
590
+
591
+
592
+ @dataclass
593
+ class DispatchConfig:
594
+ """Smart task-splitting configuration.
595
+
596
+ When ``auto_split`` is ``True`` (the default), the ``coord approve``
597
+ command analyses each proposal's ``files_likely`` list. If the file
598
+ count exceeds ``max_files_per_worker``, the work is shown to the user
599
+ split into parallel/sequential chunks for confirmation before dispatch.
600
+
601
+ Set ``auto_split: false`` to disable the splitting analysis entirely.
602
+
603
+ When ``require_plan`` is ``True``, ``coord assign`` defaults to
604
+ ``--plan-only`` behaviour — the worker reads the codebase and produces a
605
+ structured plan without writing any code. The user then runs
606
+ ``coord approve-plan`` or ``coord reject-plan`` to act on the plan.
607
+ Pass ``--no-plan`` to ``coord assign`` to override this default and
608
+ dispatch a work assignment directly. Assignments of type ``review``,
609
+ ``smoke``, or ``plan`` are never affected by this setting.
610
+ """
611
+
612
+ max_files_per_worker: int = 8
613
+ auto_split: bool = True
614
+ require_plan: bool = False
615
+
616
+
617
+ @dataclass
618
+ class UsageGateConfig:
619
+ """Pre-flight gate on the account's Max-plan 5h/weekly usage windows
620
+ (#1466). ``coord drive``'s ``preflight()`` and the ``coord approve``
621
+ batch path probe ``claude -p "/usage"`` (see ``coord.usage_limits``) and
622
+ consult this before dispatching, so a run doesn't start work that's
623
+ certain to run straight into a 5-hour or weekly wall — the first sign of
624
+ which was previously a worker dying mid-task with the branch stranded.
625
+
626
+ ``mode`` is both the on/off switch and the enforcement level:
627
+
628
+ - ``"disabled"`` — never probe, never gate. Pre-#1466 behaviour.
629
+ - ``"warn"`` — probe and print a warning above threshold, but never
630
+ refuse a dispatch. **The default** — until the probe's prose parse
631
+ (``.result`` is NOT a stable contract, see ``coord.usage_limits``'s
632
+ docstring) has enough field mileage to trust for blocking real work.
633
+ - ``"block"`` — refuse to dispatch above threshold.
634
+
635
+ A probe that fails or returns "unknown" (no OAuth subscription session,
636
+ unparseable output, timeout, ...) NEVER blocks or warns regardless of
637
+ ``mode`` — see ``coord.usage_limits.evaluate_usage_gate``.
638
+
639
+ CAVEAT: Anthropic announced ``claude -p``/Agent SDK usage moving off the
640
+ subscription windows onto a separate monthly credit pool; that rollout
641
+ is paused as of 2026-06-15, so today this gate correctly predicts a
642
+ headless worker running into the same session/weekly walls ``/usage``
643
+ reports. If the rollout resumes, this gate stops being predictive and
644
+ would need to switch to tracking credit balance instead.
645
+ """
646
+
647
+ mode: str = "warn" # "disabled" | "warn" | "block"
648
+ session_threshold_pct: float = 85.0
649
+ week_threshold_pct: float = 90.0
650
+
651
+
652
+ # #846: default wall-clock thresholds (seconds) an assignment of a given
653
+ # `type` may run before `coord.notify.detect_needs_attention` flags it.
654
+ # Deliberately generous — this is a "human should glance at this" signal,
655
+ # not a kill switch (detection + surfacing only, see issue #846).
656
+ #
657
+ # These are all *headless* types — a `claude -p` worker converging toward a
658
+ # result with no one attending it live, so "running way longer than usual"
659
+ # is a meaningful stuck signal. `plan`/`mock-author`/`test-author` are
660
+ # lighter-weight than `work` (no code-writing convergence loop) but still
661
+ # headless, so they get their own explicit (rather than work-fallback)
662
+ # tuning. `conflict-fix` is dual-purpose — the automated #241 worker *and*
663
+ # the interactive `--merge-of` session share this type (see
664
+ # `coord.reconcile.is_interactive_merge_session`) — so it gets a little more
665
+ # headroom than `work` to cover a human resolving a semantic conflict.
666
+ #
667
+ # #1137 audit note: the #1133 follow-up asked whether `merge`/`fix` (the two
668
+ # types named in the original #846 ask but left unhandled by #1133) need
669
+ # their own entry. `merge` does NOT — there is no literal `type="merge"`
670
+ # (a dedicated value was tried and reverted, see
671
+ # `is_interactive_merge_session`'s docstring / tests/test_reap_merged_sessions.py
672
+ # DISCRIMINATOR NOTE); the interactive `--merge-of` session already shares
673
+ # `conflict-fix` above and is covered by its 60m threshold. `fix` (the
674
+ # interactive `--fix-of`/`--rework-of` human-attended session) DOES need
675
+ # handling — it shares `type="work"` with headless coding workers, so it
676
+ # can't get its own entry here either. Instead `attention_threshold_for`
677
+ # recognizes it via the same compound discriminator shape as
678
+ # `is_interactive_merge_session` — `provider_name="claude-pty"` +
679
+ # `review_of_assignment_id` set on a `type="work"` row — and reuses
680
+ # `conflict-fix`'s threshold (the same "human resolving someone else's
681
+ # feedback" scenario).
682
+ #
683
+ # #1144 audit note: the same dual-purpose shape exists for `review` and
684
+ # `smoke`. Headless auto-review (`coord.review`, no `provider_name`) and
685
+ # headless smoke (`coord.smoke`, no `provider_name`) share their types with
686
+ # the interactive `--review-of`/`--smoke-of` sessions
687
+ # (`coord.commands.dispatch_workers`, `provider_name="claude-pty"` +
688
+ # `review_of_assignment_id` set) - plain `review`/`smoke` are only 15m/20m,
689
+ # so a human reading a diff or babysitting a smoke run for that long is
690
+ # normal, not stuck. `attention_threshold_for` extends the same compound
691
+ # discriminator to `assignment_type in ("work", "review", "smoke")` and
692
+ # defers all three to `conflict-fix`'s 60m threshold rather than giving
693
+ # review/smoke their own tuned value - one interactive threshold for every
694
+ # "human attending a claude-pty session tied to an earlier assignment" case
695
+ # is easier to reason about than three, and 60m is already generous enough
696
+ # to cover a human reading a diff or watching a smoke run.
697
+ _DEFAULT_ATTENTION_THRESHOLDS: dict[str, float] = {
698
+ "work": 45 * 60.0,
699
+ "review": 15 * 60.0,
700
+ "smoke": 20 * 60.0,
701
+ "plan": 30 * 60.0,
702
+ "mock-author": 30 * 60.0,
703
+ "test-author": 30 * 60.0,
704
+ "conflict-fix": 60 * 60.0,
705
+ }
706
+
707
+ # #1133: assignment types that are human-attended interactive sessions — a
708
+ # developer reading/thinking/typing at a live `claude` TTY (driven via
709
+ # `POST /inject/{id}` from the TUI), not a headless worker converging toward
710
+ # a result. These have no wall-clock "stuck" concept: a human legitimately
711
+ # spending hours reading an issue, chatting through a plan, or validating a
712
+ # diff is normal, not stalled (the #846 wall-clock check exists to catch a
713
+ # headless worker silently burning budget — see `attention_signal`'s
714
+ # docstring for the #448 motivation, which doesn't apply here). Exempt from
715
+ # the wall-clock signal unconditionally in `attention_threshold_for` —
716
+ # *not* merely by omission from `_DEFAULT_ATTENTION_THRESHOLDS` — so a user
717
+ # who overrides `pipeline.attention_thresholds.work` in `coordinator.yml`
718
+ # can't accidentally re-arm this check for a chat session via the
719
+ # fallback-to-"work" behaviour (see that method's docstring). A user who
720
+ # explicitly configures a threshold for one of these types still wins —
721
+ # this is a default exemption, not an unconditional one.
722
+ INTERACTIVE_SESSION_TYPES: frozenset[str] = frozenset({
723
+ "chat",
724
+ "troubleshoot",
725
+ "audit",
726
+ "milestone-chat",
727
+ "refinement",
728
+ "new-issue-chat",
729
+ "test-chat",
730
+ })
731
+
732
+
733
+ @dataclass
734
+ class LivenessAuditorConfig:
735
+ """#2048: cheap, independent per-turn liveness auditor tunables.
736
+
737
+ ``enabled`` (default ``False``) — the auditor ships dark. It costs a
738
+ ``claude -p`` subprocess spawn per debounced audit, so it earns trust
739
+ the same way ``auto_dispatch_stalled``/``escalate_semantic_conflicts``
740
+ did before it: off until an operator turns it on. Unlike a
741
+ daemon-side setting, a config change here does NOT need a
742
+ ``coord-serve`` restart: ``detect_liveness_stall`` only runs inside
743
+ ``coord notify``'s ``run()``, invoked as a fresh CLI process by the
744
+ ``coord-notify.timer`` systemd unit (or by ``coord drive``'s stall
745
+ nudge) — each invocation loads this config fresh off disk, so an edit
746
+ takes effect on the next timer tick (≤5 min), independent of any
747
+ long-running daemon.
748
+
749
+ ``strikes`` — consecutive ``blocked`` verdicts required before an
750
+ ``EVENT_LIVENESS_STALL`` is raised. One bad turn is normal; this many
751
+ in a row is a stall. Default 3 (matches OpenChamber's Session Goals,
752
+ the prior art this borrows from).
753
+
754
+ ``debounce_seconds`` — minimum wall-clock gap between audits for the
755
+ same assignment. A stall is a multi-minute phenomenon; auditing every
756
+ turn buys no extra signal and pays for a process spawn each time.
757
+ Default 60s.
758
+
759
+ ``model`` — the model the audit subprocess runs. Default
760
+ ``"claude-haiku-4-5"``, the cheapest current model — the audit's whole
761
+ design rests on a fixed ~1k-token context, so the cost stays flat
762
+ regardless of session length (see ``coord/liveness_auditor.py``'s
763
+ module docstring for the numbers).
764
+
765
+ ``timeout_seconds`` — subprocess timeout per audit call. Default 30s.
766
+
767
+ ``claude_bin`` — override the ``claude`` binary path/name, mirroring
768
+ other subprocess-spawning config in this file. ``None`` (default) uses
769
+ the CLI's own resolution of ``claude`` on ``$PATH``.
770
+ """
771
+
772
+ enabled: bool = False
773
+ strikes: int = DEFAULT_LIVENESS_STRIKES
774
+ debounce_seconds: float = DEFAULT_LIVENESS_DEBOUNCE_SECONDS
775
+ model: str = DEFAULT_LIVENESS_MODEL
776
+ timeout_seconds: float = DEFAULT_LIVENESS_TIMEOUT_SECONDS
777
+ claude_bin: str | None = None
778
+
779
+
780
+ @dataclass
781
+ class PipelineConfig:
782
+ """Assignment lifecycle gate configuration.
783
+
784
+ ``default_gates`` is the list of approval steps required for every work
785
+ assignment unless overridden by an issue label. ``labels`` maps GitHub
786
+ issue label names to gate lists, allowing per-label overrides — e.g.
787
+ a ``hotfix`` label could bypass review with ``hotfix: [merge]``.
788
+
789
+ ``auto_loop`` enables the automated review → fix → re-review cycle.
790
+ When ``True`` (default), a review that requests changes automatically
791
+ dispatches a fix worker. The fix worker then receives a fresh review,
792
+ and the cycle continues until the review approves or
793
+ ``max_review_iterations`` is reached.
794
+
795
+ ``max_review_iterations`` is the maximum number of fix rounds before
796
+ the auto-loop stops and posts a notice asking for manual intervention.
797
+ Default is 5.
798
+
799
+ ``escalate_fix_model`` controls whether auto-dispatched fix workers
800
+ escalate the model on each bounce iteration. When ``True`` (default),
801
+ the first fix stays on ``models.default`` and each subsequent fix
802
+ iteration climbs one rung up ``models.escalation`` (capped at the top).
803
+ When ``False``, fix dispatches set no model (today's behaviour: the
804
+ agent falls back to ``claude -p``'s default).
805
+
806
+ ``attention_thresholds`` (#846) maps assignment ``type`` (``"work"``,
807
+ ``"review"``, ``"smoke"``, ...) to a wall-clock duration (seconds) that
808
+ an assignment may sit in ``status="running"`` before
809
+ ``coord.notify.detect_needs_attention`` flags it. A type not present in
810
+ the mapping falls back to ``_DEFAULT_ATTENTION_THRESHOLDS``, *unless*
811
+ it's a human-attended interactive type (#1133,
812
+ :data:`INTERACTIVE_SESSION_TYPES` — ``"chat"``, ``"troubleshoot"``,
813
+ ``"audit"``, ``"milestone-chat"``, ``"refinement"``,
814
+ ``"new-issue-chat"``, ``"test-chat"``), which is exempt from the
815
+ wall-clock check entirely by default — see
816
+ :meth:`attention_threshold_for`. An interactive ``--fix-of``/
817
+ ``--rework-of`` session (#1137) is also recognized there, by
818
+ ``provider_name``/``review_of_assignment_id`` rather than ``type``
819
+ (it shares ``type="work"`` with headless coding workers), and reuses
820
+ ``conflict-fix``'s threshold. The interactive ``--review-of`` and
821
+ ``--smoke-of`` sessions (#1144) are recognized the same way for
822
+ ``type="review"``/``type="smoke"`` and also reuse ``conflict-fix``'s
823
+ threshold, rather than plain review's 15m or smoke's 20m.
824
+
825
+ ``escalate_semantic_conflicts`` (#1291) controls whether a conflict-fix
826
+ worker that gives up on a **semantic** conflict (it emits the
827
+ ``coord:conflict=semantic`` marker on its ``STUCK:`` line) gets ONE
828
+ second attempt from a stronger model
829
+ (``semantic_conflict_model``, default ``"fable"``) before the merge
830
+ entry is parked ``HUMAN_REQUIRED``. **Defaults to ``False``** — this
831
+ ships dark until it earns trust on real conflicts. The escalated
832
+ attempt consumes the existing one-per-entry conflict-fix retry cap, so
833
+ a second semantic failure goes to ``HUMAN_REQUIRED`` exactly as today;
834
+ there is no loop. It is still subject to every merge gate (tests,
835
+ ``coord verify-merge``, CI, review) — nothing is force-merged.
836
+
837
+ ``convergence_rounds`` (#846) is the number of fix/review rounds
838
+ (``Assignment.review_iteration``) an assignment may accumulate without
839
+ reaching a green test verdict + approved review before it is flagged as
840
+ non-converging (thrashing). Default 3.
841
+
842
+ ``auto_dispatch_stalled`` (#1478) controls whether
843
+ ``coord.notify.detect_stalled_pipeline`` (#1441) gets a dispatch arm in
844
+ addition to its diagnostic GitHub comment. Detection + narration is
845
+ always on (there is no flag for that half — it is cheap and has shipped
846
+ since #1441); this flag gates only the *action*: enqueueing an
847
+ ``approved_not_queued`` row for merge, dispatching a conflict-fix for a
848
+ merge entry stuck ``CONFLICT``, or dispatching the fix/review a
849
+ review-completion transition would have. **Defaults to ``False``** —
850
+ ships dark, same posture as ``escalate_semantic_conflicts``, until
851
+ unattended dispatch on a stalled row earns trust. The one-shot
852
+ ``notified`` ledger keyed by ``_stalled_notified_key`` (shared with the
853
+ comment) still applies, so a row is acted on once per tick-cycle, not
854
+ every 5 minutes.
855
+
856
+ ``liveness_auditor`` (#2048) configures the cheap, independent,
857
+ per-turn liveness auditor — see :class:`LivenessAuditorConfig`. Off by
858
+ default; gates nothing even when enabled (see
859
+ :func:`coord.notify.detect_liveness_stall`).
860
+ """
861
+
862
+ default_gates: list[str] = field(default_factory=lambda: ["test", "review", "merge"])
863
+ labels: dict[str, list[str]] = field(default_factory=dict)
864
+ auto_loop: bool = True
865
+ max_review_iterations: int = 5
866
+ escalate_fix_model: bool = True
867
+ # #1291 — DEFAULT OFF. See the class docstring.
868
+ escalate_semantic_conflicts: bool = False
869
+ semantic_conflict_model: str = "fable"
870
+ # #1478 — DEFAULT OFF. See the class docstring.
871
+ auto_dispatch_stalled: bool = False
872
+ attention_thresholds: dict[str, float] = field(
873
+ default_factory=lambda: dict(_DEFAULT_ATTENTION_THRESHOLDS)
874
+ )
875
+ convergence_rounds: int = 3
876
+ # #2048 — DEFAULT OFF. See LivenessAuditorConfig's docstring.
877
+ liveness_auditor: LivenessAuditorConfig = field(default_factory=LivenessAuditorConfig)
878
+
879
+ def attention_threshold_for(
880
+ self,
881
+ assignment_type: str,
882
+ *,
883
+ provider_name: str | None = None,
884
+ review_of_assignment_id: str | None = None,
885
+ ) -> float:
886
+ """Wall-clock threshold (seconds) for *assignment_type*.
887
+
888
+ Checked in order:
889
+
890
+ 1. **Interactive session sharing a headless type** (#1137/#1144):
891
+ ``assignment_type in ("work", "review", "smoke")`` with
892
+ ``provider_name == "claude-pty"`` and
893
+ ``review_of_assignment_id`` set (the optional keyword-only args,
894
+ passed by callers that have the full assignment record; both
895
+ default to ``None`` so existing callers that only know the type
896
+ are unaffected). This mirrors
897
+ :func:`coord.reconcile.is_interactive_merge_session`'s compound
898
+ discriminator — a dedicated ``type="fix"``/``type="review-of"``/
899
+ ``type="smoke-of"`` was deliberately not introduced, for the same
900
+ reason a dedicated ``type="merge"`` was reverted (see that
901
+ function's docstring): each of these three shares its type with
902
+ a headless counterpart (``work`` with headless coding workers,
903
+ ``review`` with headless auto-review, ``smoke`` with headless
904
+ smoke). A matching row defers to
905
+ ``attention_threshold_for("conflict-fix")`` — the same "human
906
+ attending a live session, not a worker silently converging"
907
+ scenario that earned conflict-fix its extra headroom in #1133 —
908
+ so an explicit user override of ``conflict-fix`` (but *not* of
909
+ plain ``work``/``review``/``smoke``) still applies. Checked
910
+ *before* the plain ``attention_thresholds`` lookup below for the
911
+ same reason :data:`INTERACTIVE_SESSION_TYPES` is:
912
+ ``attention_thresholds`` always carries built-in ``"work"``/
913
+ ``"review"``/``"smoke"`` entries (the dataclass default copies
914
+ the whole ``_DEFAULT_ATTENTION_THRESHOLDS`` dict), so checking
915
+ that dict first would make this branch unreachable.
916
+ 2. **Explicit override** — an ``attention_thresholds`` entry for
917
+ *this exact* ``assignment_type`` (built-in default or
918
+ user-configured) always wins.
919
+ 3. **Interactive session type** (#1133,
920
+ :data:`INTERACTIVE_SESSION_TYPES`) — human-attended
921
+ chat/troubleshoot/review-style sessions with no
922
+ headless-convergence concept — exempted (``inf``, never flagged)
923
+ rather than inheriting a headless-worker threshold.
924
+ 4. **Fallback to this config's own ``"work"`` entry** (so a user who
925
+ only overrides ``work`` gets that value applied to unlisted
926
+ *headless* types too, not the hardcoded default) — and only
927
+ reaches for the hardcoded default when even ``"work"`` was never
928
+ configured. This fallback is deliberately scoped to headless
929
+ types by the ``INTERACTIVE_SESSION_TYPES`` check above it: unlike
930
+ an unlisted headless type (probably work-like), an unlisted
931
+ interactive type has no wall-clock-stuck concept at all, so
932
+ silently reusing ``"work"``'s threshold for it would be a
933
+ category error, not a reasonable guess.
934
+ """
935
+ if (
936
+ assignment_type in ("work", "review", "smoke")
937
+ and provider_name == "claude-pty"
938
+ and review_of_assignment_id is not None
939
+ ):
940
+ return self.attention_threshold_for("conflict-fix")
941
+ if assignment_type in self.attention_thresholds:
942
+ return self.attention_thresholds[assignment_type]
943
+ if assignment_type in INTERACTIVE_SESSION_TYPES:
944
+ return float("inf")
945
+ return self.attention_thresholds.get(
946
+ "work", _DEFAULT_ATTENTION_THRESHOLDS["work"]
947
+ )
948
+
949
+ def tracked_labels(self) -> list[str]:
950
+ """Return the GitHub issue labels considered part of the pipeline.
951
+
952
+ Always includes ``'coord'`` so normal coordinator-tagged issues appear
953
+ in the pipeline panel regardless of per-label gate configuration.
954
+ Additional labels come from the ``labels`` dict keys, sorted for
955
+ stable ordering.
956
+ """
957
+ if not self.labels:
958
+ return ["coord"]
959
+ keys = sorted(self.labels.keys())
960
+ if "coord" not in keys:
961
+ keys = ["coord"] + keys
962
+ return keys
963
+
964
+ def gates_for_label(self, label: str | None) -> list[str]:
965
+ """Return the gate list for a specific label, falling back to defaults.
966
+
967
+ ``label`` may be ``None`` (no matching tracked label found on the
968
+ issue) — in that case the configured ``default_gates`` are returned.
969
+ """
970
+ if label and label in self.labels:
971
+ return list(self.labels[label])
972
+ return list(self.default_gates)
973
+
974
+ def test_precedes_review(self) -> bool:
975
+ """True when the ``test`` gate is ordered *before* ``review`` in the
976
+ default gate list — i.e. the smoke/test verdict gates review dispatch
977
+ (Work → Test → Review), rather than gating only the merge.
978
+
979
+ When both gates are present and ``test`` comes first, automatic review
980
+ dispatch waits for a ``passed``/``skipped`` test verdict (see
981
+ ``coord.review.dispatch_pending_reviews``); when ``review`` comes first
982
+ (or either gate is absent) review fires on work completion as before.
983
+ Consulted on the *default* policy only — this governs headless
984
+ review *dispatch* timing (``coord.review.dispatch_pending_reviews``),
985
+ which does not consult per-label overrides. This differs from the
986
+ merge gate's ``requires_smoke``/``requires_review`` (`coord/
987
+ merge_queue.py`), which *do* honour a work item's resolved
988
+ ``required_gates`` (falling back to this default list) since #1213 —
989
+ so a ``["merge"]``-only label can bypass the merge-time review/test
990
+ gates even though a review may still be auto-dispatched under the
991
+ default policy here.
992
+ """
993
+ gates = self.default_gates or []
994
+ if "test" not in gates or "review" not in gates:
995
+ return False
996
+ return gates.index("test") < gates.index("review")
997
+
998
+
999
+ @dataclass
1000
+ class MergeConfig:
1001
+ """Merge behaviour configuration.
1002
+
1003
+ ``auto_drain`` enables automatic draining of READY merge-queue entries on
1004
+ each daemon passive tick. **Default-off** — with no ``merge:`` block in
1005
+ ``coordinator.yml`` the daemon never merges automatically and existing
1006
+ behaviour is unchanged.
1007
+
1008
+ When enabled, after the enqueue step in ``_tick_loop`` the daemon calls
1009
+ :func:`coord.serve_app._auto_drain_tick`, which evaluates the plan
1010
+ (review + smoke + CI gates) and merges exactly the entries marked
1011
+ ``READY``, in true ``sequence()`` order. ``BLOCKED`` and terminal
1012
+ entries are never touched. Every auto-merge is logged so the operator
1013
+ can audit what drained (#781).
1014
+
1015
+ Set ``max_per_tick`` to cap how many merges the daemon may perform in a
1016
+ single tick (default ``0`` = unlimited).
1017
+
1018
+ ``sibling_overlap_aging_hours`` (#920) gates
1019
+ :func:`coord.merge_queue.find_sibling_overlaps` — the "these approved
1020
+ branches will conflict if merged out of order or late" warning shown by
1021
+ ``coord status`` / ``coord merge --plan``. It's the number of hours the
1022
+ oldest entry in a file-overlapping cluster of approved (PENDING) queue
1023
+ entries must have been waiting before the warning fires. ``0`` disables
1024
+ the warning entirely. Default ``24.0``.
1025
+ """
1026
+
1027
+ auto_drain: bool = False
1028
+ max_per_tick: int = 0
1029
+ auto_reap_merged: bool = True
1030
+ sibling_overlap_aging_hours: float = 24.0
1031
+
1032
+
1033
+ @dataclass
1034
+ class MilestoneConfig:
1035
+ """Milestone-driven-workflow configuration (#767 / #769 Phase 1).
1036
+
1037
+ ``auto_dispatch`` enables the daemon's tick loop to keep draining a
1038
+ milestone's declared work order after ``coord milestone dispatch``
1039
+ registers it: as issues reach a merged/terminal state, the newly-
1040
+ unblocked ready frontier is recomputed and dispatched automatically —
1041
+ no further human approval per issue, since the *declared* work order
1042
+ (the `## Work order` block) was the one-time approval unit.
1043
+ **Default-off** — with no ``milestone:`` block in ``coordinator.yml``
1044
+ the daemon never auto-dispatches and existing behaviour is unchanged;
1045
+ `coord milestone dispatch` still works as a one-shot manual drain.
1046
+
1047
+ When enabled, :func:`coord.serve_app._milestone_drain_tick` runs on
1048
+ each daemon tick (after the reconcile step) for every milestone
1049
+ registered via a non-dry-run `coord milestone dispatch` call, and
1050
+ deregisters a milestone once its whole work order reaches a terminal
1051
+ state.
1052
+
1053
+ Editing this wiring requires a **daemon restart** to take effect — the
1054
+ tick loop's closures are captured at ``coord serve`` startup time.
1055
+ """
1056
+
1057
+ auto_dispatch: bool = False
1058
+
1059
+
1060
+ @dataclass
1061
+ class CiStoreConfig:
1062
+ """Backend selection for CI check visibility (#240).
1063
+
1064
+ ``type`` is one of ``github`` (shell out to ``gh pr checks``) or
1065
+ ``none`` (always-empty :class:`coord.ci_store.NoOpCi`). When the block
1066
+ is absent we default to ``github`` since it's a no-op upgrade for users
1067
+ who already have ``gh`` configured. Future backends (GitLab, Buildkite)
1068
+ add new ``type`` values without breaking existing configs.
1069
+ """
1070
+
1071
+ type: str = "github"
1072
+
1073
+
1074
+ @dataclass
1075
+ class AuditConfig:
1076
+ """``audit:`` block (#1036/#1038) — the append-only ``audit_log``
1077
+ table's tunables.
1078
+
1079
+ ``max_rows`` is a future retention cap, not a pruning sweep: when set
1080
+ above the default ``0`` (unlimited), :func:`coord.audit.record_audit`
1081
+ opportunistically deletes the oldest rows past that count after every
1082
+ insert. ``0`` means keep everything forever — the default for this
1083
+ milestone, since retention policy is explicitly out of scope (see the
1084
+ issue's "Out of scope" section).
1085
+
1086
+ ``level`` (#1038) selects how much of the audit taxonomy is captured:
1087
+ ``"business"`` records only real board transitions (dispatch, verdicts,
1088
+ merge, ...); ``"operational"`` (the default) additionally records the
1089
+ daemon-tick's autonomic actions (passive reconcile, merge-queue
1090
+ enqueue/drain, conflict-fix dispatch, housekeeping sweeps) tagged
1091
+ ``tier="operational"``, ``actor="daemon"``. Business-tier rows are
1092
+ always recorded regardless of ``level`` — this only gates the
1093
+ operational tier.
1094
+ """
1095
+
1096
+ max_rows: int = 0
1097
+ level: str = "operational"
1098
+
1099
+
1100
+ @dataclass
1101
+ class ModelRates:
1102
+ """Per-1M-token USD rates for one canonical model (#1118 ``pricing:`` block).
1103
+
1104
+ Consumed by :mod:`coord.usage_rollup`'s cost estimator for legs that have
1105
+ no captured ``cost_usd``. All four fields default to ``0.0`` so a
1106
+ partially-specified override (e.g. only ``input``) still produces a
1107
+ valid (if incomplete) rate rather than raising.
1108
+ """
1109
+
1110
+ input: float = 0.0
1111
+ output: float = 0.0
1112
+ cache_read: float = 0.0
1113
+ cache_creation: float = 0.0
1114
+
1115
+
1116
+ def _default_pricing() -> dict[str, ModelRates]:
1117
+ """Built-in per-1M-token rates for the four canonical model tiers.
1118
+
1119
+ Official Anthropic list pricing at time of writing (Sonnet/Opus/Haiku/
1120
+ Fable input+output list price; cache_read = 0.1x input, cache_creation =
1121
+ 1.25x input, the standard 5-minute-TTL cache economics) — pinned exactly
1122
+ by ``test_pricing_absent_defaults_to_builtin_rates`` in
1123
+ ``tests/test_config_pricing.py`` so it can't silently drift again. A
1124
+ ``pricing:`` block in coordinator.yml overrides or extends any of these.
1125
+
1126
+ #1290: the Opus row previously carried ``15.00/75.00`` (Opus 3 / 4.0 /
1127
+ 4.1 era pricing, mistakenly pinned as "verified" at #1118 review).
1128
+ Current Opus (4.6 through 4.8) list price is ``5.00/25.00`` — corrected
1129
+ here. Sonnet and Haiku were already correct.
1130
+ """
1131
+ return {
1132
+ "sonnet": ModelRates(input=3.00, output=15.00, cache_read=0.30, cache_creation=3.75),
1133
+ "opus": ModelRates(input=5.00, output=25.00, cache_read=0.50, cache_creation=6.25),
1134
+ "haiku": ModelRates(input=1.00, output=5.00, cache_read=0.10, cache_creation=1.25),
1135
+ "fable": ModelRates(input=10.00, output=50.00, cache_read=1.00, cache_creation=12.50),
1136
+ }
1137
+
1138
+
1139
+ @dataclass
1140
+ class PricingConfig:
1141
+ """``pricing:`` block (#1118) — per-canonical-model per-1M-token USD rates.
1142
+
1143
+ ``models`` maps a canonical model key (``"sonnet"``, ``"opus"``,
1144
+ ``"haiku"``, or any operator-added key) to its :class:`ModelRates`. An
1145
+ absent ``pricing:`` block in coordinator.yml still yields the built-in
1146
+ defaults via :func:`_default_pricing`. A model key with no entry here
1147
+ (e.g. ``"(unknown)"``, or a genuinely unrecognized model string) has no
1148
+ rate — :mod:`coord.usage_rollup` treats that as "no estimate possible"
1149
+ and flags the group rather than silently reporting $0.
1150
+ """
1151
+
1152
+ models: dict[str, ModelRates] = field(default_factory=_default_pricing)
1153
+
1154
+ def rates_for(self, canonical_model: str) -> ModelRates | None:
1155
+ """Look up rates for a canonical model key, or ``None`` if unpriced."""
1156
+ return self.models.get(canonical_model)
1157
+
1158
+
1159
+ @dataclass
1160
+ class ProviderDef:
1161
+ """Definition of a single named worker-command provider.
1162
+
1163
+ Corresponds to one entry under ``providers.definitions`` in
1164
+ ``coordinator.yml``. All fields except ``type`` are optional.
1165
+
1166
+ Attributes:
1167
+ type: Provider backend type. Currently supported values are
1168
+ ``"claude"`` (legacy ``claude -p`` stream-json worker, the
1169
+ default) and ``"claude-pty"`` (interactive ``claude`` spawned
1170
+ inside a PTY for subscription-billed runs — see #425). The
1171
+ authoritative list of registered backends is built by
1172
+ :func:`coord.providers.build_provider`.
1173
+ binary: Override the worker binary path/name. ``None`` means the
1174
+ provider uses its own default (``"claude"`` for the claude
1175
+ backend).
1176
+ model: Pin this provider to a specific model id or alias. Used as
1177
+ the ``--model`` fallback in the provider's ``build_command``
1178
+ when neither an explicit per-call ``resolved_model`` nor
1179
+ ``AssignmentSpec.model`` is set. For definitions whose ``type``
1180
+ is **not** ``"claude"``/``"claude-pty"`` (e.g. ``"opencode"``),
1181
+ ``coord/dispatch.py``'s model resolution is provider-aware
1182
+ (#1706 review fix): when a dispatch has no explicit ``--model``
1183
+ and no label-routed model, and the effective provider's
1184
+ definition pins a ``model`` here, ``models.default`` is *not*
1185
+ applied and ``AssignmentSpec.model`` is left unset — so this
1186
+ field wins for the common "pin opencode to a model once" case.
1187
+ For definitions whose ``type`` IS ``claude``/``claude-pty``
1188
+ (regardless of what name they're registered under — see the
1189
+ ``fast-claude`` example in ``coordinator.example.yml``),
1190
+ ``models.default`` still always wins over this field when no
1191
+ explicit override is given, because ``AssignmentSpec.model``
1192
+ for those backends must go through ``models.resolve()``'s
1193
+ alias -> exact-id translation (``models.versions``), which a
1194
+ raw ``model`` value here would bypass.
1195
+ attach_url: Reserved for future attach-mode providers.
1196
+ env: Extra environment variables for the worker subprocess.
1197
+ Values may contain ``${VAR}`` placeholders which are expanded
1198
+ from :data:`os.environ` at parse time.
1199
+ extra_args: Additional command-line arguments appended to the
1200
+ worker argv.
1201
+ """
1202
+
1203
+ type: str
1204
+ binary: str | None = None
1205
+ model: str | None = None
1206
+ attach_url: str | None = None
1207
+ env: dict[str, str] = field(default_factory=dict)
1208
+ extra_args: list[str] = field(default_factory=list)
1209
+
1210
+
1211
+ @dataclass
1212
+ class ProvidersConfig:
1213
+ """Global provider registry.
1214
+
1215
+ Parsed from the optional ``providers:`` block in ``coordinator.yml``.
1216
+ When the block is absent, ``default == "claude"`` and an implicit
1217
+ ``"claude"`` definition is present in ``definitions``.
1218
+
1219
+ Attributes:
1220
+ default: The provider name used when no per-spec, per-label, or
1221
+ per-repo override is set. Defaults to ``"claude"``.
1222
+ definitions: Named provider definitions keyed by provider name.
1223
+ An implicit ``"claude"`` entry is always materialised if absent.
1224
+ labels: Per-issue-label provider override (#1889), e.g.
1225
+ ``{"harness:opencode": "opencode"}`` — mirrors
1226
+ :attr:`ModelsConfig.labels`' shape and precedent, including its
1227
+ provenance reporting (:meth:`model_for_labels_with_reason`
1228
+ here becomes :meth:`provider_for_labels_with_reason`). Resolved
1229
+ via :func:`coord.providers.resolve_provider_name`'s
1230
+ ``issue_labels`` param, slotted into the precedence chain
1231
+ between *spec_provider* and *repo_provider* — see that
1232
+ function's docstring for the full chain. Values are validated
1233
+ against ``definitions`` at parse time in ``_parse_providers``
1234
+ (mirrors ``reviews.provider``, #1811): an unknown provider name
1235
+ here is a config-load error, not a dispatch-time surprise
1236
+ discovered at 2am. Every dispatch site gates this to
1237
+ ``type="work"`` proposals only, the same restriction
1238
+ ``models.labels`` uses (#1430) — plan/review/smoke dispatches
1239
+ must not inherit a harness-eval label meant for the eventual
1240
+ work dispatch.
1241
+ """
1242
+
1243
+ default: str = "claude"
1244
+ definitions: dict[str, ProviderDef] = field(default_factory=dict)
1245
+ labels: dict[str, str] = field(default_factory=dict)
1246
+
1247
+ def __post_init__(self) -> None:
1248
+ # Always ensure the implicit "claude" definition exists so callers
1249
+ # can look it up by name without checking for its presence.
1250
+ if "claude" not in self.definitions:
1251
+ self.definitions["claude"] = ProviderDef(type="claude")
1252
+
1253
+ def provider_for_labels(self, issue_labels: list[str]) -> str | None:
1254
+ """Resolve an issue's GitHub labels to a provider name via ``labels``.
1255
+
1256
+ #1889: mirrors :meth:`ModelsConfig.model_for_labels` — see
1257
+ :meth:`provider_for_labels_with_reason` for the full precedence
1258
+ rule. Returns ``None`` (never *default* or *repo_provider*) when no
1259
+ configured label is present on the issue, or ``labels`` itself is
1260
+ empty. Callers are expected to fall back to *repo_provider*/
1261
+ ``default`` themselves (:func:`coord.providers.resolve_provider_name`
1262
+ does this).
1263
+ """
1264
+ return self.provider_for_labels_with_reason(issue_labels)[0]
1265
+
1266
+ def provider_for_labels_with_reason(
1267
+ self, issue_labels: list[str]
1268
+ ) -> tuple[str | None, str | None, list[str]]:
1269
+ """Like :meth:`provider_for_labels`, but also returns the label that
1270
+ matched and any configured labels it shadowed.
1271
+
1272
+ Returns ``(provider, matched_label, shadowed_labels)``, or
1273
+ ``(None, None, [])`` under the same conditions
1274
+ :meth:`provider_for_labels` returns ``None``.
1275
+
1276
+ #1889: mirrors :meth:`ModelsConfig.model_for_labels_with_reason`
1277
+ (#1633)'s provenance shape, minus the ``tier:*`` grouping —
1278
+ ``providers.labels`` has no size-tier concept, so precedence among
1279
+ several configured labels present on the same issue (e.g. both
1280
+ ``harness:opencode`` and ``harness:claude``) is simply ``labels``'s
1281
+ own declaration order in ``coordinator.yml``, exactly like
1282
+ ``models.labels``' non-tier group. Ties are NOT broken by the
1283
+ issue's own label order (GitHub-controlled, not config-controlled)
1284
+ for the same reason #1633 fixed that for models: the same issue +
1285
+ config must always resolve to the same provider, regardless of
1286
+ what order GitHub reports the issue's labels in.
1287
+ """
1288
+ if not self.labels:
1289
+ return None, None, []
1290
+ present_in_config_order = [
1291
+ label for label in self.labels if label in issue_labels
1292
+ ]
1293
+ if not present_in_config_order:
1294
+ return None, None, []
1295
+ matched = present_in_config_order[0]
1296
+ shadowed = present_in_config_order[1:]
1297
+ return self.labels[matched], matched, shadowed
1298
+
1299
+
1300
+ # #1711: provider-availability capability vocabulary.
1301
+ #
1302
+ # A machine advertises support for a given provider *backend type* the same
1303
+ # way it advertises "rust"/"gtk"/"browser" — one more string in
1304
+ # `machines[].capabilities`, e.g. `"provider:opencode"`. A dedicated
1305
+ # `Machine.provider` field was considered and rejected: provider and machine
1306
+ # are already orthogonal in this data model (a machine has no provider
1307
+ # opinion of its own — `Repo.provider`/`providers.default` decide that), and
1308
+ # "can this machine run backend X" is exactly the shape `smoke_tests.
1309
+ # capability_rules` and `coord.prereqs` already solve for "rust"/"gtk"/
1310
+ # "browser". Reusing that machinery (rather than inventing a parallel
1311
+ # provider-routing concept) means `coord doctor`'s declared-vs-probed report
1312
+ # and the `coordinator.yml` capability list both fall out for free.
1313
+ #
1314
+ # Keyed off `ProviderDef.type` (not the definition's arbitrary registered
1315
+ # NAME) because type is what actually determines which binary a machine
1316
+ # needs installed. An operator-named alias of a claude-backed provider (e.g.
1317
+ # `fast-claude` in `coordinator.example.yml`, `type: claude`) never needs a
1318
+ # capability declared — it still just runs the `claude` CLI. Only a
1319
+ # genuinely different backend TYPE (today: `opencode`) needs one.
1320
+ #
1321
+ # `claude` and `claude-pty` are the IMPLICIT baseline: every machine is
1322
+ # assumed to already have the `claude` CLI (this predates #1711, and
1323
+ # probing for the `claude` binary itself is out of this issue's scope —
1324
+ # see its non-goals), so neither needs a capability declared. This is what
1325
+ # keeps every existing no-``providers:``-block deployment unaffected.
1326
+ IMPLICIT_PROVIDER_TYPES: frozenset[str] = frozenset({"claude", "claude-pty"})
1327
+
1328
+
1329
+ def provider_capability(provider_type: str) -> str:
1330
+ """The ``capabilities:`` string a machine advertises to declare it can
1331
+ run *provider_type* (#1711).
1332
+
1333
+ ``provider_capability("opencode") == "provider:opencode"``. Single
1334
+ source of truth for the naming convention — every caller that declares,
1335
+ checks, or probes provider availability (`coord doctor`'s prereq
1336
+ manifest, `coord.providers.guard_provider_machine_capability`, `coord
1337
+ plan`'s proposal filter) calls this rather than hand-formatting the
1338
+ ``"provider:" + name`` string, so the convention can't drift between
1339
+ call sites.
1340
+ """
1341
+ return f"provider:{provider_type}"
1342
+
1343
+
1344
+ def model_plausible_for_provider_type(model: str, provider_type: str) -> bool:
1345
+ """Namespace-shape sanity check: could *model* plausibly belong to
1346
+ *provider_type* (#1798)?
1347
+
1348
+ A cheap, syntax-only heuristic — NOT a live catalog lookup (no network
1349
+ call, no per-provider model list to keep in sync). Every model
1350
+ identifier this coordinator actually resolves falls into one of two
1351
+ shapes, mirroring the two namespaces ``coord.dispatch.
1352
+ resolve_dispatch_model_alias`` already reasons about:
1353
+
1354
+ * Claude aliases (``sonnet``, ``opus``, ``haiku``) and exact ids
1355
+ resolved via ``models.versions`` (``claude-sonnet-4-6``) — never
1356
+ contain a ``/``.
1357
+ * OpenCode Zen model strings are always ``provider/model``
1358
+ (``opencode/glm-5.2``, ``deepseek/deepseek-chat`` — see
1359
+ ``docs/OPENCODE_VERIFICATION.md``) — always contain a ``/``.
1360
+
1361
+ That's enough signal to catch #1798's actual failure mode — a Claude
1362
+ alias handed to an opencode-type backend (or vice versa) — before a
1363
+ dispatch spends a worktree and a network round-trip discovering the
1364
+ backend rejects it mid-run. ``claude``/``claude-pty``
1365
+ (:data:`IMPLICIT_PROVIDER_TYPES`) require NO ``/``; every other
1366
+ (non-implicit) provider type requires one.
1367
+ """
1368
+ has_namespace = "/" in model
1369
+ if provider_type in IMPLICIT_PROVIDER_TYPES:
1370
+ return not has_namespace
1371
+ return has_namespace
1372
+
1373
+
1374
+ # #1628: default disk mount points the health engine probes. "/" and "/home"
1375
+ # are usually the two that matter (and were the two that mattered on
1376
+ # 2026-07-30 — elitebook's /home hit 0 bytes free); "~/.coord" is separate
1377
+ # because on some layouts the coordinator state dir is its own filesystem.
1378
+ # Probing the same *device* twice is deduped at run time, so a machine with
1379
+ # one big root filesystem still reports one line.
1380
+ _DEFAULT_HEALTH_DISK_PATHS = ("/", "/home", "~/.coord")
1381
+
1382
+ # The systemd *user* units `spawned_coord` (#1834) introspects. Duplicated
1383
+ # from `coord.health.checks.spawned_coord.DEFAULT_UNITS` rather than imported,
1384
+ # same reason as AGENT_PORT in coord/commands/_common.py: config must not
1385
+ # import the check registry (probes import config, not the other way round).
1386
+ # tests/test_release_verify.py pins the two lists together.
1387
+ _DEFAULT_SPAWNED_COORD_UNITS = (
1388
+ "coord-serve",
1389
+ "coord-agent",
1390
+ "coord-web",
1391
+ "coord-drive-queue",
1392
+ "coord-notify",
1393
+ )
1394
+
1395
+
1396
+ @dataclass
1397
+ class HealthConfig:
1398
+ """Thresholds for the fleet-health check engine (#1628, ``coord health``).
1399
+
1400
+ Every number here is a *default that would have caught the 2026-07-30
1401
+ incidents* — see ``tests/test_health_incident_regression.py``, which
1402
+ replays the recorded values against these defaults. Loosening one is
1403
+ therefore a decision to stop catching a class of failure that has
1404
+ already happened once, not a tuning preference; the regression test is
1405
+ there to make that trade explicit rather than accidental.
1406
+
1407
+ Disk thresholds are expressed as **percent free remaining** (headroom),
1408
+ not percent used, because headroom is the question the engine answers.
1409
+ """
1410
+
1411
+ # Master switch. False makes `coord health` report nothing rather than
1412
+ # being removed from the CLI — a disabled check must still be visible.
1413
+ enabled: bool = True
1414
+ # Check ids to skip, e.g. ["plan_usage"] on a machine with no OAuth login.
1415
+ disabled_checks: list[str] = field(default_factory=list)
1416
+
1417
+ # ── disk free ─────────────────────────────────────────────────────────
1418
+ disk_paths: list[str] = field(
1419
+ default_factory=lambda: list(_DEFAULT_HEALTH_DISK_PATHS)
1420
+ )
1421
+ disk_warn_free_pct: float = 15.0
1422
+ disk_crit_free_pct: float = 7.0
1423
+
1424
+ # ── cargo target/ total across known dirs ─────────────────────────────
1425
+ cargo_target_warn_gb: float = 40.0
1426
+ cargo_target_crit_gb: float = 60.0
1427
+ # Extra target dirs to total beyond the shared per-machine cache and each
1428
+ # known checkout's own target/.
1429
+ cargo_target_extra_dirs: list[str] = field(default_factory=list)
1430
+ # Walking a 78G target dir is not free. The probe totals what it can in
1431
+ # this many seconds and reports a partial scan rather than blowing the
1432
+ # ~2s registry budget; a partial total is a *lower bound*, so a CRIT
1433
+ # derived from one is still trustworthy.
1434
+ cargo_scan_budget_secs: float = 1.5
1435
+
1436
+ # ── stale worktrees under ~/.coord/worktrees ──────────────────────────
1437
+ # "Stale" here is deliberately DB-free (see the probe's docstring): a
1438
+ # worktree directory untouched for this long. Counting live assignments
1439
+ # would need board state, which is H-3's job, not this child's.
1440
+ worktree_stale_hours: float = 48.0
1441
+ worktree_warn_count: int = 3
1442
+ worktree_crit_count: int = 10
1443
+
1444
+ # ── agent install ─────────────────────────────────────────────────────
1445
+ # Absolute path to the agent venv's python. None → autodetect
1446
+ # (~/.coord-venv/bin/python3, else the running interpreter).
1447
+ agent_venv_python: str | None = None
1448
+ agent_version_warn_behind: int = 1
1449
+ agent_version_crit_behind: int = 2
1450
+ # The *simple index*, not the JSON API: they flip independently in both
1451
+ # directions and only the simple index is what pip actually resolves
1452
+ # against, so a JSON-API answer can say "current" while pip disagrees.
1453
+ pypi_index_url: str = "https://pypi.org/simple"
1454
+ network_timeout_secs: float = 3.0
1455
+
1456
+ # ── graphify graph freshness ──────────────────────────────────────────
1457
+ # A stale graph whose checkout has hooks disabled is CRIT regardless of
1458
+ # age: it structurally cannot self-heal, so time only makes it worse.
1459
+ graph_stale_warn_hours: float = 24.0
1460
+ graph_stale_crit_hours: float = 72.0
1461
+
1462
+ # ── Max-plan usage windows (wraps coord.usage_limits) ─────────────────
1463
+ plan_usage_warn_pct: float = 85.0
1464
+ plan_usage_crit_pct: float = 95.0
1465
+
1466
+ # ── fleet deploy lanes, daemon host only (#1630) ──────────────────────
1467
+ # These three name the two deploy lanes that live on the *daemon* host
1468
+ # rather than on an agent: the operator's CLI venv, and the locally-built
1469
+ # coord-tui binary. All three follow `agent_venv_python`'s convention —
1470
+ # ``None`` means "use the documented default location", NOT "disable the
1471
+ # lane" — so the lanes are live on a stock install with no config at all,
1472
+ # and an operator only sets them when their layout differs.
1473
+ #
1474
+ # Absolute path to the operator CLI venv's python. None →
1475
+ # ~/.coord-cli-venv/bin/python3 (what the install docs create). This lane
1476
+ # exists because it was found three releases stale on 2026-07-29.
1477
+ cli_venv_python: str | None = None
1478
+ # Absolute path to the built coord-tui binary. None → ~/.local/bin/coord-tui
1479
+ # (README: `cd tui && cargo build && cp target/debug/coord-tui
1480
+ # ~/.local/bin/coord-tui`).
1481
+ tui_binary_path: str | None = None
1482
+ # Directory holding the tui/ Rust sources the binary was built from. None →
1483
+ # `<checkout>/tui/src` for the first configured local checkout that has one.
1484
+ # Deliberately points at `src/`, not the crate root: rooting the mtime walk
1485
+ # above `target/` would sweep a multi-GB build dir on every refresh.
1486
+ tui_source_dir: str | None = None
1487
+ # Absolute path to the live `coord web --dist` bundle (#1834 lane 5).
1488
+ # None → ~/coord-web-dist — the symlink `deploy/coord-web-dist-build.timer`
1489
+ # atomically repoints at each new release (#1543).
1490
+ webapp_dist_path: str | None = None
1491
+ # Directory holding the coord/dashboard/webapp/ sources the bundle was
1492
+ # built from. None → `<checkout>/coord/dashboard/webapp/src` for the
1493
+ # first configured local checkout that has one. Same `src/`-not-root
1494
+ # reasoning as `tui_source_dir`: rooting at the webapp package root would
1495
+ # sweep `node_modules`/`dist` were they not already skipped by name.
1496
+ webapp_source_dir: str | None = None
1497
+
1498
+ # ── systemd unit-file drift (#1831) ────────────────────────────────────
1499
+ # `deploy/*.service`/`*.timer` is version-controlled and reviewed but
1500
+ # nothing installs it — a unit hand-copied at machine setup drifts
1501
+ # forever from what's checked in. These two follow the same convention
1502
+ # as the deploy-lane paths above: None means "use the documented default
1503
+ # location", not "disable the check".
1504
+ #
1505
+ # The reference directory — a FALLBACK only, since #1927. The check now
1506
+ # diffs against `coord/deploy/` inside the installed distribution (the
1507
+ # released artifact for the running version, which cannot drift with
1508
+ # this host); both this setting and the checkout scan below apply only
1509
+ # when the installed wheel ships no units of its own, and whatever they
1510
+ # point at is reported as an unverified working copy.
1511
+ # None -> `<checkout>/deploy` for the first configured local checkout
1512
+ # that has one (normally the code-coordinator checkout in repo_paths).
1513
+ deploy_dir: str | None = None
1514
+ # Where systemd user units actually live. None -> ~/.config/systemd/user.
1515
+ systemd_user_dir: str | None = None
1516
+
1517
+ # ── what a running service actually spawns (#1834) ────────────────────
1518
+ # The systemd user units whose LIVE process environment `spawned_coord`
1519
+ # reads to predict which `coord` binary their subprocesses will get.
1520
+ # Unlike the path-ish options above, an EMPTY list here really does mean
1521
+ # "off" — the unit names are the check's entire subject, so there is no
1522
+ # documented default to fall back to once they are cleared.
1523
+ spawned_coord_units: list[str] = field(
1524
+ default_factory=lambda: list(_DEFAULT_SPAWNED_COORD_UNITS)
1525
+ )
1526
+
1527
+
1528
+ @dataclass
1529
+ class Config:
1530
+ repos: list[Repo]
1531
+ machines: list[Machine]
1532
+ hooks: HooksConfig = field(default_factory=HooksConfig)
1533
+ reviews: ReviewsConfig = field(default_factory=ReviewsConfig)
1534
+ concurrency: ConcurrencyConfig = field(default_factory=ConcurrencyConfig)
1535
+ smoke_tests: SmokeTestsConfig = field(default_factory=SmokeTestsConfig)
1536
+ acceptance: AcceptanceConfig = field(default_factory=AcceptanceConfig)
1537
+ models: ModelsConfig = field(default_factory=ModelsConfig)
1538
+ pipeline: PipelineConfig = field(default_factory=PipelineConfig)
1539
+ dispatch: DispatchConfig = field(default_factory=DispatchConfig)
1540
+ usage_gate: UsageGateConfig = field(default_factory=UsageGateConfig)
1541
+ ci_store: CiStoreConfig = field(default_factory=CiStoreConfig)
1542
+ merge: MergeConfig = field(default_factory=MergeConfig)
1543
+ milestone: MilestoneConfig = field(default_factory=MilestoneConfig)
1544
+ providers: ProvidersConfig = field(default_factory=ProvidersConfig)
1545
+ audit: AuditConfig = field(default_factory=AuditConfig)
1546
+ pricing: PricingConfig = field(default_factory=PricingConfig)
1547
+ health: HealthConfig = field(default_factory=HealthConfig)
1548
+ path: Path | None = None
1549
+
1550
+ def repo(self, name: str) -> Repo | None:
1551
+ return next((r for r in self.repos if r.name == name), None)
1552
+
1553
+
1554
+ def load(path: str | Path | None = None) -> Config:
1555
+ """Load and validate a coordinator.yml file.
1556
+
1557
+ When ``path`` is None the location is resolved via
1558
+ :func:`resolve_config_path` (``$COORD_CONFIG`` → ``~/.coord/coordinator.yml``
1559
+ → ``./coordinator.yml``), so the tool works on a machine without a repo
1560
+ checkout.
1561
+ """
1562
+ p = Path(path).expanduser() if path is not None else resolve_config_path()
1563
+ if not p.exists():
1564
+ raise ConfigError(
1565
+ f"Config file not found: {p}. Create it at {USER_CONFIG_PATH} "
1566
+ f"(recommended — works without a repo checkout), pass --config <path>, "
1567
+ f"or set $COORD_CONFIG."
1568
+ )
1569
+
1570
+ try:
1571
+ raw = yaml.safe_load(p.read_text())
1572
+ except yaml.YAMLError as e:
1573
+ raise ConfigError(f"Invalid YAML in {p}: {e}") from e
1574
+
1575
+ if raw is None:
1576
+ raise ConfigError(f"Config file is empty: {p}")
1577
+
1578
+ return parse_mapping(raw, path=p)
1579
+
1580
+
1581
+ def parse_mapping(raw: Any, *, path: Path | None = None) -> Config:
1582
+ """Validate an already-decoded coordinator.yml *mapping* into a :class:`Config`.
1583
+
1584
+ :func:`load` is this plus "read the YAML off disk first". Split out (#1538)
1585
+ so callers that already hold the mapping — notably the ``coord web
1586
+ --fixture`` seeded-board server, whose fixture JSON may carry an inline
1587
+ ``config`` block — get the identical parsing/validation instead of a
1588
+ second, drifting hand-rolled Config builder.
1589
+ """
1590
+ if not isinstance(raw, dict):
1591
+ raise ConfigError(f"Top-level config must be a mapping, got {type(raw).__name__}")
1592
+
1593
+ p = path
1594
+ repos = _parse_repos(raw.get("repos"))
1595
+ machines = _parse_machines(raw.get("machines"), repos)
1596
+ _validate_dependencies(repos)
1597
+ hooks = _parse_hooks(raw.get("hooks"))
1598
+ # #1811: providers parsed before reviews so `reviews.provider` can be
1599
+ # validated against `providers.definitions` at parse time — mirrors how
1600
+ # `reviews.repo_overrides` validates against `repo_names` above.
1601
+ providers = _parse_providers(raw.get("providers"))
1602
+ reviews = _parse_reviews(
1603
+ raw.get("reviews"), {r.name for r in repos}, set(providers.definitions),
1604
+ )
1605
+ concurrency = _parse_concurrency(raw.get("concurrency"))
1606
+ smoke_tests = _parse_smoke_tests(raw.get("smoke_tests"))
1607
+ acceptance = _parse_acceptance(raw.get("acceptance"))
1608
+ models = _parse_models(raw.get("models"))
1609
+ pipeline = _parse_pipeline(raw.get("pipeline"))
1610
+ dispatch = _parse_dispatch(raw.get("dispatch"))
1611
+ usage_gate = _parse_usage_gate(raw.get("usage_gate"))
1612
+ ci_store = _parse_ci_store(raw.get("ci_store"))
1613
+ merge = _parse_merge(raw.get("merge"))
1614
+ milestone = _parse_milestone(raw.get("milestone"))
1615
+ audit = _parse_audit(raw.get("audit"))
1616
+ pricing = _parse_pricing(raw.get("pricing"))
1617
+ health = _parse_health(raw.get("health"))
1618
+
1619
+ return Config(
1620
+ repos=repos,
1621
+ machines=machines,
1622
+ hooks=hooks,
1623
+ reviews=reviews,
1624
+ concurrency=concurrency,
1625
+ smoke_tests=smoke_tests,
1626
+ acceptance=acceptance,
1627
+ models=models,
1628
+ pipeline=pipeline,
1629
+ dispatch=dispatch,
1630
+ usage_gate=usage_gate,
1631
+ ci_store=ci_store,
1632
+ merge=merge,
1633
+ milestone=milestone,
1634
+ providers=providers,
1635
+ audit=audit,
1636
+ pricing=pricing,
1637
+ health=health,
1638
+ path=p,
1639
+ )
1640
+
1641
+
1642
+ def _parse_repos(raw: Any) -> list[Repo]:
1643
+ if raw is None:
1644
+ raise ConfigError("Config must define 'repos'")
1645
+ if not isinstance(raw, list):
1646
+ raise ConfigError("'repos' must be a list")
1647
+ if not raw:
1648
+ raise ConfigError("'repos' must contain at least one repo")
1649
+
1650
+ repos: list[Repo] = []
1651
+ seen: set[str] = set()
1652
+ for i, entry in enumerate(raw):
1653
+ if not isinstance(entry, dict):
1654
+ raise ConfigError(f"repos[{i}] must be a mapping, got {type(entry).__name__}")
1655
+ name = entry.get("name")
1656
+ github = entry.get("github")
1657
+ if not name or not isinstance(name, str):
1658
+ raise ConfigError(f"repos[{i}].name is required (string)")
1659
+ if not github or not isinstance(github, str):
1660
+ raise ConfigError(f"repos[{i}].github is required (string, 'owner/repo')")
1661
+ if "/" not in github:
1662
+ raise ConfigError(
1663
+ f"repos[{i}].github must be 'owner/repo', got {github!r}"
1664
+ )
1665
+ if name in seen:
1666
+ raise ConfigError(f"duplicate repo name: {name!r}")
1667
+ seen.add(name)
1668
+
1669
+ depends_on = entry.get("depends_on", []) or []
1670
+ if not isinstance(depends_on, list) or not all(isinstance(d, str) for d in depends_on):
1671
+ raise ConfigError(f"repos[{i}].depends_on must be a list of repo names")
1672
+
1673
+ default_branch = entry.get("default_branch", "main")
1674
+ if not isinstance(default_branch, str):
1675
+ raise ConfigError(f"repos[{i}].default_branch must be a string")
1676
+
1677
+ # #934: develop_branch — opt-in to the develop + feature-branch-
1678
+ # per-milestone git model (docs/PIPELINE_V2.md "Git model"). Absent
1679
+ # (None) by default so existing repos are unaffected.
1680
+ develop_branch = entry.get("develop_branch")
1681
+ if develop_branch is not None and not isinstance(develop_branch, str):
1682
+ raise ConfigError(f"repos[{i}].develop_branch must be a string")
1683
+
1684
+ build_command = entry.get("build_command")
1685
+ if build_command is not None and not isinstance(build_command, str):
1686
+ raise ConfigError(f"repos[{i}].build_command must be a string")
1687
+ test_command = entry.get("test_command")
1688
+ if test_command is not None and not isinstance(test_command, str):
1689
+ raise ConfigError(f"repos[{i}].test_command must be a string")
1690
+ # #296: run_cmd — optional shell command to launch the app for manual
1691
+ # smoke testing. Surfaced in the TUI Test stage detail panel.
1692
+ run_cmd = entry.get("run_cmd")
1693
+ if run_cmd is not None and not isinstance(run_cmd, str):
1694
+ raise ConfigError(f"repos[{i}].run_cmd must be a string")
1695
+
1696
+ worker_permissions = _parse_worker_permissions(entry.get("worker_permissions"), i)
1697
+
1698
+ housekeeping = entry.get("housekeeping", []) or []
1699
+ if not isinstance(housekeeping, list) or not all(isinstance(h, str) for h in housekeeping):
1700
+ raise ConfigError(f"repos[{i}].housekeeping must be a list of strings")
1701
+
1702
+ coordinator_only_files = entry.get("coordinator_only_files", []) or []
1703
+ if not isinstance(coordinator_only_files, list) or not all(isinstance(f, str) for f in coordinator_only_files):
1704
+ raise ConfigError(f"repos[{i}].coordinator_only_files must be a list of strings")
1705
+
1706
+ # #268: reference_repos — sibling repos a worker may reference
1707
+ # for context but doesn't actually build against.
1708
+ reference_repos = entry.get("reference_repos", []) or []
1709
+ if not isinstance(reference_repos, list) or not all(isinstance(r, str) for r in reference_repos):
1710
+ raise ConfigError(f"repos[{i}].reference_repos must be a list of repo names")
1711
+
1712
+ # #316: new_issue_guidance — inline markdown or repo-relative file path.
1713
+ new_issue_guidance = entry.get("new_issue_guidance")
1714
+ if new_issue_guidance is not None and not isinstance(new_issue_guidance, str):
1715
+ raise ConfigError(f"repos[{i}].new_issue_guidance must be a string")
1716
+
1717
+ # #305: artifact_paths — glob patterns for build artifacts to stash.
1718
+ artifact_paths_raw = entry.get("artifact_paths", []) or []
1719
+ if not isinstance(artifact_paths_raw, list):
1720
+ raise ConfigError(f"repos[{i}].artifact_paths must be a list of strings")
1721
+ for j, p in enumerate(artifact_paths_raw):
1722
+ if not isinstance(p, str):
1723
+ raise ConfigError(
1724
+ f"repos[{i}].artifact_paths[{j}] must be a string, "
1725
+ f"got {type(p).__name__}"
1726
+ )
1727
+ artifact_paths: list[str] = list(artifact_paths_raw)
1728
+
1729
+ # #323: optional per-repo provider override.
1730
+ repo_provider = entry.get("provider")
1731
+ if repo_provider is not None and not isinstance(repo_provider, str):
1732
+ raise ConfigError(f"repos[{i}].provider must be a string")
1733
+
1734
+ repos.append(
1735
+ Repo(
1736
+ name=name,
1737
+ github=github,
1738
+ depends_on=depends_on,
1739
+ default_branch=default_branch,
1740
+ develop_branch=develop_branch,
1741
+ build_command=build_command,
1742
+ test_command=test_command,
1743
+ run_cmd=run_cmd,
1744
+ worker_permissions=worker_permissions,
1745
+ housekeeping=housekeeping,
1746
+ coordinator_only_files=coordinator_only_files,
1747
+ reference_repos=reference_repos,
1748
+ new_issue_guidance=new_issue_guidance,
1749
+ artifact_paths=artifact_paths,
1750
+ provider=repo_provider,
1751
+ )
1752
+ )
1753
+ return repos
1754
+
1755
+
1756
+ def _parse_worker_permissions(raw: Any, repo_index: int) -> WorkerPermissionsConfig:
1757
+ """Parse the ``worker_permissions`` block for a single repo.
1758
+
1759
+ When *raw* is ``None`` (key absent from YAML), the default deny-list is
1760
+ applied — safety by default. An explicit ``deny: []`` clears restrictions.
1761
+ """
1762
+ if raw is None:
1763
+ return WorkerPermissionsConfig(deny=list(DEFAULT_DENY_COMMANDS))
1764
+
1765
+ if not isinstance(raw, dict):
1766
+ raise ConfigError(
1767
+ f"repos[{repo_index}].worker_permissions must be a mapping"
1768
+ )
1769
+
1770
+ allow = raw.get("allow", []) or []
1771
+ if not isinstance(allow, list) or not all(isinstance(a, str) for a in allow):
1772
+ raise ConfigError(
1773
+ f"repos[{repo_index}].worker_permissions.allow must be a list of strings"
1774
+ )
1775
+
1776
+ deny = raw.get("deny", []) or []
1777
+ if not isinstance(deny, list) or not all(isinstance(d, str) for d in deny):
1778
+ raise ConfigError(
1779
+ f"repos[{repo_index}].worker_permissions.deny must be a list of strings"
1780
+ )
1781
+
1782
+ return WorkerPermissionsConfig(allow=allow, deny=deny)
1783
+
1784
+
1785
+ # #1862: 24h "HH:MM" — the only shape `quiet_hours.start`/`.end` accept.
1786
+ _HHMM_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
1787
+
1788
+
1789
+ def _parse_hhmm(raw: Any, *, field_path: str) -> time:
1790
+ if not isinstance(raw, str):
1791
+ raise ConfigError(
1792
+ f"{field_path} must be a 24h 'HH:MM' string, got {type(raw).__name__}"
1793
+ )
1794
+ m = _HHMM_RE.match(raw)
1795
+ if not m:
1796
+ raise ConfigError(f"{field_path} must be 24h 'HH:MM' (e.g. '23:00'), got {raw!r}")
1797
+ return time(int(m.group(1)), int(m.group(2)))
1798
+
1799
+
1800
+ def _parse_quiet_hours(raw: Any, *, machine_index: int, machine_name: str) -> QuietHours | None:
1801
+ """Parse ``machines[i].quiet_hours`` (#1862). ``None`` input → ``None``
1802
+ (no window — the default, unchanged-behaviour case).
1803
+
1804
+ ``tz`` is REQUIRED and validated against the IANA database: `coord
1805
+ serve` runs on UTC, so a naive time-of-day compared against the
1806
+ daemon's own clock would silently fire hours off from what a non-UTC
1807
+ operator wrote — a quiet-hours feature that activates early is worse
1808
+ than none, because it silently pulls the machine out of the fleet
1809
+ during the working day. Fail loudly here rather than default quietly.
1810
+ """
1811
+ if raw is None:
1812
+ return None
1813
+ prefix = f"machines[{machine_index}] ({machine_name!r}).quiet_hours"
1814
+ if not isinstance(raw, dict):
1815
+ raise ConfigError(f"{prefix} must be a mapping")
1816
+
1817
+ start = _parse_hhmm(raw.get("start"), field_path=f"{prefix}.start")
1818
+ end = _parse_hhmm(raw.get("end"), field_path=f"{prefix}.end")
1819
+
1820
+ tz = raw.get("tz")
1821
+ if not tz or not isinstance(tz, str):
1822
+ raise ConfigError(
1823
+ f"{prefix}.tz is required (IANA zone name, e.g. 'America/Chicago') — "
1824
+ "quiet hours never default to the daemon's own UTC clock, since that "
1825
+ "would silently fire at the wrong local hour"
1826
+ )
1827
+ try:
1828
+ ZoneInfo(tz)
1829
+ except (ZoneInfoNotFoundError, ValueError, OSError) as e:
1830
+ raise ConfigError(f"{prefix}.tz {tz!r} is not a known IANA zone name: {e}") from e
1831
+
1832
+ if start == end:
1833
+ raise ConfigError(
1834
+ f"{prefix}: start and end must differ ('always quiet' is ambiguous — "
1835
+ "use `coord pause` to take a machine out of rotation indefinitely instead)"
1836
+ )
1837
+
1838
+ return QuietHours(start=start, end=end, tz=tz)
1839
+
1840
+
1841
+ def _parse_machines(raw: Any, repos: list[Repo]) -> list[Machine]:
1842
+ if raw is None:
1843
+ raise ConfigError("Config must define 'machines'")
1844
+ if not isinstance(raw, list):
1845
+ raise ConfigError("'machines' must be a list")
1846
+ if not raw:
1847
+ raise ConfigError("'machines' must contain at least one machine")
1848
+
1849
+ repo_names = {r.name for r in repos}
1850
+ machines: list[Machine] = []
1851
+ seen: set[str] = set()
1852
+ for i, entry in enumerate(raw):
1853
+ if not isinstance(entry, dict):
1854
+ raise ConfigError(f"machines[{i}] must be a mapping, got {type(entry).__name__}")
1855
+ name = entry.get("name")
1856
+ host = entry.get("host")
1857
+ if not name or not isinstance(name, str):
1858
+ raise ConfigError(f"machines[{i}].name is required (string)")
1859
+ if not host or not isinstance(host, str):
1860
+ raise ConfigError(f"machines[{i}].host is required (string, tailscale hostname)")
1861
+ if name in seen:
1862
+ raise ConfigError(f"duplicate machine name: {name!r}")
1863
+ seen.add(name)
1864
+
1865
+ capabilities = entry.get("capabilities", []) or []
1866
+ if not isinstance(capabilities, list) or not all(isinstance(c, str) for c in capabilities):
1867
+ raise ConfigError(f"machines[{i}].capabilities must be a list of strings")
1868
+
1869
+ machine_repos = entry.get("repos", []) or []
1870
+ if not isinstance(machine_repos, list) or not all(isinstance(r, str) for r in machine_repos):
1871
+ raise ConfigError(f"machines[{i}].repos must be a list of repo names")
1872
+
1873
+ unknown = [r for r in machine_repos if r not in repo_names]
1874
+ if unknown:
1875
+ raise ConfigError(
1876
+ f"machines[{i}] ({name!r}) references unknown repos: {unknown}"
1877
+ )
1878
+
1879
+ repo_paths = entry.get("repo_paths", {}) or {}
1880
+ if not isinstance(repo_paths, dict) or not all(
1881
+ isinstance(k, str) and isinstance(v, str) for k, v in repo_paths.items()
1882
+ ):
1883
+ raise ConfigError(f"machines[{i}].repo_paths must be a mapping of repo name → local path")
1884
+ unknown_paths = [r for r in repo_paths if r not in repo_names]
1885
+ if unknown_paths:
1886
+ raise ConfigError(
1887
+ f"machines[{i}] ({name!r}) repo_paths references unknown repos: {unknown_paths}"
1888
+ )
1889
+
1890
+ # #1417: optional per-machine capacity override. `None` (unset)
1891
+ # means "use concurrency.max_workers" — see Machine.max_workers.
1892
+ machine_max_workers = entry.get("max_workers")
1893
+ if machine_max_workers is not None:
1894
+ if isinstance(machine_max_workers, bool) or not isinstance(machine_max_workers, int):
1895
+ raise ConfigError(f"machines[{i}].max_workers must be an integer")
1896
+ if machine_max_workers < 1:
1897
+ raise ConfigError(f"machines[{i}].max_workers must be at least 1")
1898
+
1899
+ quiet_hours = _parse_quiet_hours(
1900
+ entry.get("quiet_hours"), machine_index=i, machine_name=name,
1901
+ )
1902
+
1903
+ machines.append(
1904
+ Machine(
1905
+ name=name,
1906
+ host=host,
1907
+ capabilities=capabilities,
1908
+ repos=machine_repos,
1909
+ repo_paths=repo_paths,
1910
+ max_workers=machine_max_workers,
1911
+ quiet_hours=quiet_hours,
1912
+ )
1913
+ )
1914
+ return machines
1915
+
1916
+
1917
+ KNOWN_HOOKS = {"close_merged_issues", "summary_report"}
1918
+
1919
+
1920
+ def _parse_hooks(raw: Any) -> HooksConfig:
1921
+ if raw is None:
1922
+ return HooksConfig()
1923
+ if not isinstance(raw, dict):
1924
+ raise ConfigError("'hooks' must be a mapping")
1925
+ hooks = HooksConfig()
1926
+ for event_name in ("on_round_complete", "on_session_end"):
1927
+ entries = raw.get(event_name)
1928
+ if entries is None:
1929
+ continue
1930
+ if not isinstance(entries, list) or not all(isinstance(e, str) for e in entries):
1931
+ raise ConfigError(f"hooks.{event_name} must be a list of hook names")
1932
+ unknown = [e for e in entries if e not in KNOWN_HOOKS]
1933
+ if unknown:
1934
+ raise ConfigError(
1935
+ f"hooks.{event_name} references unknown hooks: {unknown}. "
1936
+ f"Known: {sorted(KNOWN_HOOKS)}"
1937
+ )
1938
+ setattr(hooks, event_name, entries)
1939
+ return hooks
1940
+
1941
+
1942
+ def _parse_reviews(
1943
+ raw: Any, repo_names: set[str], provider_names: set[str] | None = None,
1944
+ ) -> ReviewsConfig:
1945
+ if raw is None:
1946
+ return ReviewsConfig()
1947
+ if not isinstance(raw, dict):
1948
+ raise ConfigError("'reviews' must be a mapping")
1949
+
1950
+ cfg = ReviewsConfig()
1951
+
1952
+ # #1811: reviews.provider — validated against providers.definitions the
1953
+ # same way reviews.repo_overrides is validated against repo_names below,
1954
+ # so an unknown name is a config error at parse time, not a silent
1955
+ # dispatch-time fallback.
1956
+ if "provider" in raw:
1957
+ value = raw["provider"]
1958
+ if not isinstance(value, str) or not value:
1959
+ raise ConfigError("reviews.provider must be a non-empty string")
1960
+ if provider_names is not None and value not in provider_names:
1961
+ raise ConfigError(
1962
+ f"reviews.provider references unknown provider: {value!r}"
1963
+ )
1964
+ cfg.provider = value
1965
+
1966
+ for bool_field in ("enabled", "auto_dispatch", "require_approval", "allow_review_flood"):
1967
+ if bool_field in raw:
1968
+ value = raw[bool_field]
1969
+ if not isinstance(value, bool):
1970
+ raise ConfigError(f"reviews.{bool_field} must be a boolean")
1971
+ setattr(cfg, bool_field, value)
1972
+
1973
+ for int_field in ("max_auto_dispatch_per_pass", "flood_threshold", "reaffirm_max_diff_lines"):
1974
+ if int_field in raw:
1975
+ value = raw[int_field]
1976
+ # bool is a subclass of int — reject it explicitly so a stray
1977
+ # `flood_threshold: true` doesn't silently become 1.
1978
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
1979
+ raise ConfigError(f"reviews.{int_field} must be a non-negative integer")
1980
+ setattr(cfg, int_field, value)
1981
+
1982
+ if "reviewer_prompt" in raw:
1983
+ value = raw["reviewer_prompt"]
1984
+ if not isinstance(value, str):
1985
+ raise ConfigError("reviews.reviewer_prompt must be a string")
1986
+ cfg.reviewer_prompt = value
1987
+
1988
+ checklist = raw.get("checklist", []) or []
1989
+ if not isinstance(checklist, list) or not all(isinstance(c, str) for c in checklist):
1990
+ raise ConfigError("reviews.checklist must be a list of strings")
1991
+ cfg.checklist = checklist
1992
+
1993
+ overrides = raw.get("repo_overrides", {}) or {}
1994
+ if not isinstance(overrides, dict):
1995
+ raise ConfigError("reviews.repo_overrides must be a mapping of repo → list of strings")
1996
+ for repo_name, items in overrides.items():
1997
+ if not isinstance(repo_name, str):
1998
+ raise ConfigError("reviews.repo_overrides keys must be repo names")
1999
+ if repo_name not in repo_names:
2000
+ raise ConfigError(
2001
+ f"reviews.repo_overrides references unknown repo: {repo_name!r}"
2002
+ )
2003
+ if not isinstance(items, list) or not all(isinstance(i, str) for i in items):
2004
+ raise ConfigError(
2005
+ f"reviews.repo_overrides[{repo_name}] must be a list of strings"
2006
+ )
2007
+ cfg.repo_overrides = overrides
2008
+ return cfg
2009
+
2010
+
2011
+ def _parse_concurrency(raw: Any) -> ConcurrencyConfig:
2012
+ if raw is None:
2013
+ return ConcurrencyConfig()
2014
+ if not isinstance(raw, dict):
2015
+ raise ConfigError("'concurrency' must be a mapping")
2016
+ cfg = ConcurrencyConfig()
2017
+ for key in (
2018
+ "max_workers", "stagger_seconds", "backoff_base", "max_retries",
2019
+ "stale_threshold", "first_output_timeout", "interactive_session_timeout_hours",
2020
+ ):
2021
+ val = raw.get(key)
2022
+ if val is None:
2023
+ continue
2024
+ if key in ("max_retries", "max_workers", "stale_threshold"):
2025
+ if not isinstance(val, int) or val < 0:
2026
+ raise ConfigError(f"concurrency.{key} must be a non-negative integer")
2027
+ else:
2028
+ # bool is a subclass of int — reject it explicitly for numeric keys.
2029
+ if isinstance(val, bool) or not isinstance(val, (int, float)) or val < 0:
2030
+ raise ConfigError(f"concurrency.{key} must be a non-negative number")
2031
+ setattr(cfg, key, val)
2032
+ if "auto_reassign" in raw:
2033
+ val = raw["auto_reassign"]
2034
+ if not isinstance(val, bool):
2035
+ raise ConfigError("concurrency.auto_reassign must be a boolean")
2036
+ cfg.auto_reassign = val
2037
+ if "bash_wrap_spawn" in raw:
2038
+ val = raw["bash_wrap_spawn"]
2039
+ if not isinstance(val, bool):
2040
+ raise ConfigError("concurrency.bash_wrap_spawn must be a boolean")
2041
+ cfg.bash_wrap_spawn = val
2042
+ return cfg
2043
+
2044
+
2045
+ def _parse_smoke_tests(raw: Any) -> SmokeTestsConfig:
2046
+ if raw is None:
2047
+ return SmokeTestsConfig()
2048
+ if not isinstance(raw, dict):
2049
+ raise ConfigError("'smoke_tests' must be a mapping")
2050
+
2051
+ cfg = SmokeTestsConfig()
2052
+ if "auto_queue" in raw:
2053
+ value = raw["auto_queue"]
2054
+ if not isinstance(value, bool):
2055
+ raise ConfigError("smoke_tests.auto_queue must be a boolean")
2056
+ cfg.auto_queue = value
2057
+
2058
+ if "default_command" in raw:
2059
+ value = raw["default_command"]
2060
+ if value is not None and not isinstance(value, str):
2061
+ raise ConfigError("smoke_tests.default_command must be a string")
2062
+ cfg.default_command = value
2063
+
2064
+ if "timeout_seconds" in raw:
2065
+ value = raw["timeout_seconds"]
2066
+ if not isinstance(value, int) or value <= 0:
2067
+ raise ConfigError("smoke_tests.timeout_seconds must be a positive integer")
2068
+ cfg.timeout_seconds = value
2069
+
2070
+ rules_raw = raw.get("capability_rules", []) or []
2071
+ if not isinstance(rules_raw, list):
2072
+ raise ConfigError("smoke_tests.capability_rules must be a list")
2073
+ rules: list[SmokeRule] = []
2074
+ for i, entry in enumerate(rules_raw):
2075
+ if not isinstance(entry, dict):
2076
+ raise ConfigError(
2077
+ f"smoke_tests.capability_rules[{i}] must be a mapping"
2078
+ )
2079
+ files = entry.get("files", []) or []
2080
+ requires = entry.get("requires", []) or []
2081
+ if not isinstance(files, list) or not all(isinstance(f, str) for f in files):
2082
+ raise ConfigError(
2083
+ f"smoke_tests.capability_rules[{i}].files must be a list of strings"
2084
+ )
2085
+ if not isinstance(requires, list) or not all(isinstance(r, str) for r in requires):
2086
+ raise ConfigError(
2087
+ f"smoke_tests.capability_rules[{i}].requires must be a list of strings"
2088
+ )
2089
+ if not files:
2090
+ raise ConfigError(
2091
+ f"smoke_tests.capability_rules[{i}].files must be non-empty"
2092
+ )
2093
+ if not requires:
2094
+ raise ConfigError(
2095
+ f"smoke_tests.capability_rules[{i}].requires must be non-empty"
2096
+ )
2097
+ rules.append(SmokeRule(files=files, requires=requires))
2098
+ cfg.capability_rules = rules
2099
+ return cfg
2100
+
2101
+
2102
+ def _parse_acceptance(raw: Any) -> AcceptanceConfig:
2103
+ """Parse the ``acceptance:`` block (#944, docs/ORACLE_LOOP.md).
2104
+
2105
+ ``acceptance.drivers`` maps a local repo name (as declared under
2106
+ ``repos:``) to its driver config. Absent entirely -> no repo has a sealed
2107
+ acceptance suite, and ``coord acceptance run/record`` refuses with a
2108
+ clear error rather than guessing a default.
2109
+ """
2110
+ if raw is None:
2111
+ return AcceptanceConfig()
2112
+ if not isinstance(raw, dict):
2113
+ raise ConfigError("'acceptance' must be a mapping")
2114
+
2115
+ drivers_raw = raw.get("drivers", {}) or {}
2116
+ if not isinstance(drivers_raw, dict):
2117
+ raise ConfigError(
2118
+ "acceptance.drivers must be a mapping of repo name -> driver config"
2119
+ )
2120
+
2121
+ drivers: dict[str, AcceptanceDriverConfig] = {}
2122
+ for repo_name, entry in drivers_raw.items():
2123
+ if not isinstance(entry, dict):
2124
+ raise ConfigError(f"acceptance.drivers[{repo_name!r}] must be a mapping")
2125
+
2126
+ routes_raw = entry.get("routes")
2127
+ if routes_raw is not None:
2128
+ # #1125 review finding 5: a routed entry's flat kind/run/mock/
2129
+ # capability fields are unused (each route carries its own) — an
2130
+ # operator who sets both almost certainly meant one or the
2131
+ # other, so reject it rather than silently discarding the flat
2132
+ # fields.
2133
+ flat_fields = [
2134
+ f for f in ("kind", "run", "mock", "capability", "setup", "entrypoint")
2135
+ if entry.get(f)
2136
+ ]
2137
+ if flat_fields:
2138
+ raise ConfigError(
2139
+ f"acceptance.drivers[{repo_name!r}] sets both 'routes' "
2140
+ f"and flat field(s) {flat_fields!r} — a routed entry's "
2141
+ "driver is entirely per-route; remove the flat fields "
2142
+ "(they would otherwise be silently ignored)"
2143
+ )
2144
+ drivers[repo_name] = AcceptanceDriverConfig(
2145
+ routes=_parse_acceptance_routes(repo_name, routes_raw),
2146
+ )
2147
+ continue
2148
+
2149
+ kind = entry.get("kind")
2150
+ if not kind or not isinstance(kind, str):
2151
+ raise ConfigError(f"acceptance.drivers[{repo_name!r}].kind is required")
2152
+
2153
+ run = entry.get("run")
2154
+ if not run or not isinstance(run, str):
2155
+ raise ConfigError(f"acceptance.drivers[{repo_name!r}].run is required")
2156
+
2157
+ mock = entry.get("mock", "") or ""
2158
+ if not isinstance(mock, str):
2159
+ raise ConfigError(f"acceptance.drivers[{repo_name!r}].mock must be a string")
2160
+
2161
+ capability = entry.get("capability", "") or ""
2162
+ if not isinstance(capability, str):
2163
+ raise ConfigError(
2164
+ f"acceptance.drivers[{repo_name!r}].capability must be a string"
2165
+ )
2166
+
2167
+ setup = entry.get("setup", "") or ""
2168
+ if not isinstance(setup, str):
2169
+ raise ConfigError(f"acceptance.drivers[{repo_name!r}].setup must be a string")
2170
+
2171
+ entrypoint = _acceptance_entrypoint(
2172
+ entry, f"acceptance.drivers[{repo_name!r}].entrypoint"
2173
+ )
2174
+
2175
+ drivers[repo_name] = AcceptanceDriverConfig(
2176
+ kind=kind, run=run, mock=mock, capability=capability, setup=setup,
2177
+ entrypoint=entrypoint,
2178
+ )
2179
+
2180
+ return AcceptanceConfig(drivers=drivers)
2181
+
2182
+
2183
+ def _parse_acceptance_routes(
2184
+ repo_name: str, routes_raw: Any,
2185
+ ) -> list[AcceptanceDriverConfig]:
2186
+ """Parse ``acceptance.drivers.<repo_name>.routes`` (#1125) into a list of
2187
+ ``AcceptanceDriverConfig`` route entries, each with ``match`` set.
2188
+
2189
+ Each element is validated the same way as a flat driver entry
2190
+ (``kind``/``run`` required, ``mock``/``capability``/``setup`` optional
2191
+ strings), plus a required ``match`` glob.
2192
+ """
2193
+ if not isinstance(routes_raw, list) or not routes_raw:
2194
+ raise ConfigError(
2195
+ f"acceptance.drivers[{repo_name!r}].routes must be a non-empty list"
2196
+ )
2197
+
2198
+ routes: list[AcceptanceDriverConfig] = []
2199
+ for i, route_entry in enumerate(routes_raw):
2200
+ if not isinstance(route_entry, dict):
2201
+ raise ConfigError(
2202
+ f"acceptance.drivers[{repo_name!r}].routes[{i}] must be a mapping"
2203
+ )
2204
+
2205
+ match = route_entry.get("match")
2206
+ if not match or not isinstance(match, str):
2207
+ raise ConfigError(
2208
+ f"acceptance.drivers[{repo_name!r}].routes[{i}].match is required"
2209
+ )
2210
+
2211
+ kind = route_entry.get("kind")
2212
+ if not kind or not isinstance(kind, str):
2213
+ raise ConfigError(
2214
+ f"acceptance.drivers[{repo_name!r}].routes[{i}].kind is required"
2215
+ )
2216
+
2217
+ run = route_entry.get("run")
2218
+ if not run or not isinstance(run, str):
2219
+ raise ConfigError(
2220
+ f"acceptance.drivers[{repo_name!r}].routes[{i}].run is required"
2221
+ )
2222
+
2223
+ mock = route_entry.get("mock", "") or ""
2224
+ if not isinstance(mock, str):
2225
+ raise ConfigError(
2226
+ f"acceptance.drivers[{repo_name!r}].routes[{i}].mock must be a string"
2227
+ )
2228
+
2229
+ capability = route_entry.get("capability", "") or ""
2230
+ if not isinstance(capability, str):
2231
+ raise ConfigError(
2232
+ f"acceptance.drivers[{repo_name!r}].routes[{i}].capability must be a string"
2233
+ )
2234
+
2235
+ setup = route_entry.get("setup", "") or ""
2236
+ if not isinstance(setup, str):
2237
+ raise ConfigError(
2238
+ f"acceptance.drivers[{repo_name!r}].routes[{i}].setup must be a string"
2239
+ )
2240
+
2241
+ entrypoint = _acceptance_entrypoint(
2242
+ route_entry, f"acceptance.drivers[{repo_name!r}].routes[{i}].entrypoint"
2243
+ )
2244
+
2245
+ routes.append(
2246
+ AcceptanceDriverConfig(
2247
+ kind=kind, run=run, mock=mock, capability=capability, setup=setup,
2248
+ match=match, entrypoint=entrypoint,
2249
+ )
2250
+ )
2251
+
2252
+ return routes
2253
+
2254
+
2255
+ def _acceptance_entrypoint(entry: dict, label: str) -> str:
2256
+ """Validate an acceptance driver's optional ``entrypoint:`` (#1552).
2257
+
2258
+ Must be a repo-root-relative *file* path — it is folded into the sealed
2259
+ set as an exact-match entry (:meth:`AcceptanceConfig.sealed_paths`), so
2260
+ an absolute path or a trailing-slash directory would silently seal
2261
+ nothing at all. Reject both here rather than at review time, where the
2262
+ only symptom would be a `test-author` bounced for a scope violation it
2263
+ cannot fix.
2264
+ """
2265
+ raw = entry.get("entrypoint", "") or ""
2266
+ if not isinstance(raw, str):
2267
+ raise ConfigError(f"{label} must be a string")
2268
+ value = raw.strip()
2269
+ if not value:
2270
+ return ""
2271
+ if value.startswith("/") or value.startswith("~"):
2272
+ raise ConfigError(
2273
+ f"{label} must be repo-root-relative, not an absolute path "
2274
+ f"(got {value!r})"
2275
+ )
2276
+ if value.endswith("/"):
2277
+ raise ConfigError(
2278
+ f"{label} must name a FILE (the driver's crate-root/entry point), "
2279
+ f"not a directory (got {value!r})"
2280
+ )
2281
+ return value
2282
+
2283
+
2284
+ def _parse_models(raw: Any) -> ModelsConfig:
2285
+ if raw is None:
2286
+ return ModelsConfig()
2287
+ if not isinstance(raw, dict):
2288
+ raise ConfigError("'models' must be a mapping")
2289
+
2290
+ cfg = ModelsConfig()
2291
+ if "default" in raw:
2292
+ value = raw["default"]
2293
+ if not isinstance(value, str) or not value:
2294
+ raise ConfigError("models.default must be a non-empty string")
2295
+ cfg.default = value
2296
+
2297
+ if "escalation" in raw:
2298
+ value = raw["escalation"]
2299
+ if not isinstance(value, list) or not all(isinstance(v, str) and v for v in value):
2300
+ raise ConfigError("models.escalation must be a list of non-empty strings")
2301
+ cfg.escalation = list(value)
2302
+
2303
+ if "labels" in raw:
2304
+ value = raw["labels"]
2305
+ if not isinstance(value, dict) or not all(
2306
+ isinstance(k, str) and isinstance(v, str) for k, v in value.items()
2307
+ ):
2308
+ raise ConfigError(
2309
+ "models.labels must be a mapping of label name → model alias"
2310
+ )
2311
+ cfg.labels = dict(value)
2312
+
2313
+ if "versions" in raw:
2314
+ value = raw["versions"]
2315
+ if not isinstance(value, dict) or not all(
2316
+ isinstance(k, str) and k and isinstance(v, str) and v
2317
+ for k, v in value.items()
2318
+ ):
2319
+ raise ConfigError(
2320
+ "models.versions must be a mapping of alias → exact model id"
2321
+ )
2322
+ cfg.versions = dict(value)
2323
+
2324
+ return cfg
2325
+
2326
+
2327
+ def _parse_pipeline(raw: Any) -> PipelineConfig:
2328
+ if raw is None:
2329
+ return PipelineConfig()
2330
+ if not isinstance(raw, dict):
2331
+ raise ConfigError("'pipeline' must be a mapping")
2332
+
2333
+ cfg = PipelineConfig()
2334
+
2335
+ if "default_gates" in raw:
2336
+ value = raw["default_gates"]
2337
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
2338
+ raise ConfigError("pipeline.default_gates must be a list of strings")
2339
+ cfg.default_gates = list(value)
2340
+
2341
+ if "labels" in raw:
2342
+ value = raw["labels"]
2343
+ if not isinstance(value, dict):
2344
+ raise ConfigError("pipeline.labels must be a mapping of label → list of strings")
2345
+ for k, v in value.items():
2346
+ if not isinstance(k, str):
2347
+ raise ConfigError("pipeline.labels keys must be strings")
2348
+ if not isinstance(v, list) or not all(isinstance(g, str) for g in v):
2349
+ raise ConfigError(
2350
+ f"pipeline.labels[{k!r}] must be a list of gate name strings"
2351
+ )
2352
+ cfg.labels = {k: list(v) for k, v in value.items()}
2353
+
2354
+ if "auto_loop" in raw:
2355
+ value = raw["auto_loop"]
2356
+ if not isinstance(value, bool):
2357
+ raise ConfigError("pipeline.auto_loop must be a boolean")
2358
+ cfg.auto_loop = value
2359
+
2360
+ if "max_review_iterations" in raw:
2361
+ value = raw["max_review_iterations"]
2362
+ if not isinstance(value, int) or value < 1:
2363
+ raise ConfigError("pipeline.max_review_iterations must be a positive integer")
2364
+ cfg.max_review_iterations = value
2365
+
2366
+ if "escalate_fix_model" in raw:
2367
+ value = raw["escalate_fix_model"]
2368
+ if not isinstance(value, bool):
2369
+ raise ConfigError("pipeline.escalate_fix_model must be a boolean")
2370
+ cfg.escalate_fix_model = value
2371
+
2372
+ if "escalate_semantic_conflicts" in raw:
2373
+ value = raw["escalate_semantic_conflicts"]
2374
+ if not isinstance(value, bool):
2375
+ raise ConfigError("pipeline.escalate_semantic_conflicts must be a boolean")
2376
+ cfg.escalate_semantic_conflicts = value
2377
+
2378
+ if "semantic_conflict_model" in raw:
2379
+ value = raw["semantic_conflict_model"]
2380
+ if not isinstance(value, str) or not value.strip():
2381
+ raise ConfigError("pipeline.semantic_conflict_model must be a non-empty string")
2382
+ cfg.semantic_conflict_model = value.strip()
2383
+
2384
+ if "auto_dispatch_stalled" in raw:
2385
+ value = raw["auto_dispatch_stalled"]
2386
+ if not isinstance(value, bool):
2387
+ raise ConfigError("pipeline.auto_dispatch_stalled must be a boolean")
2388
+ cfg.auto_dispatch_stalled = value
2389
+
2390
+ if "attention_thresholds" in raw:
2391
+ value = raw["attention_thresholds"]
2392
+ if not isinstance(value, dict):
2393
+ raise ConfigError(
2394
+ "pipeline.attention_thresholds must be a mapping of "
2395
+ "assignment type -> duration (e.g. '45m', '15m', or seconds)"
2396
+ )
2397
+ parsed: dict[str, float] = {}
2398
+ for k, v in value.items():
2399
+ if not isinstance(k, str):
2400
+ raise ConfigError("pipeline.attention_thresholds keys must be strings")
2401
+ parsed[k] = _parse_duration_seconds(
2402
+ v, context=f"pipeline.attention_thresholds[{k!r}]"
2403
+ )
2404
+ cfg.attention_thresholds = parsed
2405
+
2406
+ if "convergence_rounds" in raw:
2407
+ value = raw["convergence_rounds"]
2408
+ if not isinstance(value, int) or value < 1:
2409
+ raise ConfigError("pipeline.convergence_rounds must be a positive integer")
2410
+ cfg.convergence_rounds = value
2411
+
2412
+ if "liveness_auditor" in raw:
2413
+ cfg.liveness_auditor = _parse_liveness_auditor(raw["liveness_auditor"])
2414
+
2415
+ return cfg
2416
+
2417
+
2418
+ def _parse_liveness_auditor(raw: Any) -> LivenessAuditorConfig:
2419
+ if raw is None:
2420
+ return LivenessAuditorConfig()
2421
+ if not isinstance(raw, dict):
2422
+ raise ConfigError("'pipeline.liveness_auditor' must be a mapping")
2423
+
2424
+ cfg = LivenessAuditorConfig()
2425
+
2426
+ if "enabled" in raw:
2427
+ value = raw["enabled"]
2428
+ if not isinstance(value, bool):
2429
+ raise ConfigError("pipeline.liveness_auditor.enabled must be a boolean")
2430
+ cfg.enabled = value
2431
+
2432
+ if "strikes" in raw:
2433
+ value = raw["strikes"]
2434
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
2435
+ raise ConfigError("pipeline.liveness_auditor.strikes must be a positive integer")
2436
+ cfg.strikes = value
2437
+
2438
+ if "debounce_seconds" in raw:
2439
+ cfg.debounce_seconds = _parse_duration_seconds(
2440
+ raw["debounce_seconds"], context="pipeline.liveness_auditor.debounce_seconds"
2441
+ )
2442
+
2443
+ if "model" in raw:
2444
+ value = raw["model"]
2445
+ if not isinstance(value, str) or not value.strip():
2446
+ raise ConfigError("pipeline.liveness_auditor.model must be a non-empty string")
2447
+ cfg.model = value.strip()
2448
+
2449
+ if "timeout_seconds" in raw:
2450
+ cfg.timeout_seconds = _parse_duration_seconds(
2451
+ raw["timeout_seconds"], context="pipeline.liveness_auditor.timeout_seconds"
2452
+ )
2453
+
2454
+ if "claude_bin" in raw:
2455
+ value = raw["claude_bin"]
2456
+ if value is not None and (not isinstance(value, str) or not value.strip()):
2457
+ raise ConfigError(
2458
+ "pipeline.liveness_auditor.claude_bin must be a non-empty string or null"
2459
+ )
2460
+ cfg.claude_bin = value.strip() if isinstance(value, str) else None
2461
+
2462
+ return cfg
2463
+
2464
+
2465
+ _DURATION_UNIT_SECONDS: dict[str, float] = {
2466
+ "s": 1.0,
2467
+ "m": 60.0,
2468
+ "h": 3600.0,
2469
+ "d": 86400.0,
2470
+ }
2471
+
2472
+
2473
+ def _parse_duration_seconds(value: Any, *, context: str) -> float:
2474
+ """Parse a duration into seconds. Accepts a bare number (seconds) or a
2475
+ string like ``"45m"``, ``"15m"``, ``"2h"``, ``"90s"``. Used for
2476
+ ``pipeline.attention_thresholds`` (#846)."""
2477
+ if isinstance(value, bool):
2478
+ raise ConfigError(f"{context} must be a number of seconds or a duration string")
2479
+ if isinstance(value, (int, float)):
2480
+ if value <= 0:
2481
+ raise ConfigError(f"{context} must be a positive duration")
2482
+ return float(value)
2483
+ if isinstance(value, str):
2484
+ text = value.strip().lower()
2485
+ if text and text[-1] in _DURATION_UNIT_SECONDS and text[:-1].strip():
2486
+ number_part = text[:-1].strip()
2487
+ try:
2488
+ number = float(number_part)
2489
+ except ValueError:
2490
+ pass
2491
+ else:
2492
+ if number <= 0:
2493
+ raise ConfigError(f"{context} must be a positive duration")
2494
+ return number * _DURATION_UNIT_SECONDS[text[-1]]
2495
+ try:
2496
+ number = float(text)
2497
+ except ValueError:
2498
+ raise ConfigError(
2499
+ f"{context} must be a number of seconds or a duration string "
2500
+ f"like '45m', '15m', '2h' (got {value!r})"
2501
+ ) from None
2502
+ if number <= 0:
2503
+ raise ConfigError(f"{context} must be a positive duration")
2504
+ return number
2505
+ raise ConfigError(f"{context} must be a number of seconds or a duration string")
2506
+
2507
+
2508
+ def _parse_dispatch(raw: Any) -> DispatchConfig:
2509
+ if raw is None:
2510
+ return DispatchConfig()
2511
+ if not isinstance(raw, dict):
2512
+ raise ConfigError("'dispatch' must be a mapping")
2513
+
2514
+ cfg = DispatchConfig()
2515
+
2516
+ if "max_files_per_worker" in raw:
2517
+ value = raw["max_files_per_worker"]
2518
+ if not isinstance(value, int) or value < 1:
2519
+ raise ConfigError("dispatch.max_files_per_worker must be a positive integer")
2520
+ cfg.max_files_per_worker = value
2521
+
2522
+ if "auto_split" in raw:
2523
+ value = raw["auto_split"]
2524
+ if not isinstance(value, bool):
2525
+ raise ConfigError("dispatch.auto_split must be a boolean")
2526
+ cfg.auto_split = value
2527
+
2528
+ if "require_plan" in raw:
2529
+ value = raw["require_plan"]
2530
+ if not isinstance(value, bool):
2531
+ raise ConfigError("dispatch.require_plan must be a boolean")
2532
+ cfg.require_plan = value
2533
+
2534
+ return cfg
2535
+
2536
+
2537
+ def _parse_usage_gate(raw: Any) -> UsageGateConfig:
2538
+ if raw is None:
2539
+ return UsageGateConfig()
2540
+ if not isinstance(raw, dict):
2541
+ raise ConfigError("'usage_gate' must be a mapping")
2542
+
2543
+ cfg = UsageGateConfig()
2544
+
2545
+ if "mode" in raw:
2546
+ value = raw["mode"]
2547
+ if value not in ("disabled", "warn", "block"):
2548
+ raise ConfigError("usage_gate.mode must be one of: disabled, warn, block")
2549
+ cfg.mode = value
2550
+
2551
+ for key in ("session_threshold_pct", "week_threshold_pct"):
2552
+ if key in raw:
2553
+ value = raw[key]
2554
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not (0 <= value <= 100):
2555
+ raise ConfigError(f"usage_gate.{key} must be a number between 0 and 100")
2556
+ setattr(cfg, key, float(value))
2557
+
2558
+ return cfg
2559
+
2560
+
2561
+ # #1628: (field name, minimum) for every numeric knob in the `health:` block.
2562
+ # A single table rather than a per-field branch, because the whole point of
2563
+ # the block is that adding a check adds a threshold — and if adding one meant
2564
+ # hand-writing another eight-line validator, the "adding a check touches one
2565
+ # file" property would rot on the config side instead.
2566
+ _HEALTH_FLOAT_FIELDS: dict[str, float] = {
2567
+ "disk_warn_free_pct": 0.0,
2568
+ "disk_crit_free_pct": 0.0,
2569
+ "cargo_target_warn_gb": 0.0,
2570
+ "cargo_target_crit_gb": 0.0,
2571
+ "cargo_scan_budget_secs": 0.0,
2572
+ "worktree_stale_hours": 0.0,
2573
+ "network_timeout_secs": 0.0,
2574
+ "graph_stale_warn_hours": 0.0,
2575
+ "graph_stale_crit_hours": 0.0,
2576
+ "plan_usage_warn_pct": 0.0,
2577
+ "plan_usage_crit_pct": 0.0,
2578
+ }
2579
+ _HEALTH_INT_FIELDS: tuple[str, ...] = (
2580
+ "worktree_warn_count",
2581
+ "worktree_crit_count",
2582
+ "agent_version_warn_behind",
2583
+ "agent_version_crit_behind",
2584
+ )
2585
+ _HEALTH_STR_LIST_FIELDS: tuple[str, ...] = (
2586
+ "disabled_checks",
2587
+ "disk_paths",
2588
+ "cargo_target_extra_dirs",
2589
+ "spawned_coord_units",
2590
+ )
2591
+ # Path-ish overrides: a string, or null to mean "use the documented default".
2592
+ # Table-driven for the same reason as the numeric fields above — a new deploy
2593
+ # lane should not need its own hand-written eight-line validator.
2594
+ _HEALTH_OPT_STR_FIELDS: tuple[str, ...] = (
2595
+ "agent_venv_python",
2596
+ "cli_venv_python",
2597
+ "tui_binary_path",
2598
+ "tui_source_dir",
2599
+ "deploy_dir",
2600
+ "systemd_user_dir",
2601
+ "webapp_dist_path",
2602
+ "webapp_source_dir",
2603
+ )
2604
+ # Pairs that must not be inverted. A config where warn is stricter than crit
2605
+ # silently makes the crit level unreachable — the check keeps reporting WARN
2606
+ # for a machine that is actually on fire, which is exactly the failure this
2607
+ # milestone exists to prevent. Reject it at load rather than at 3am.
2608
+ _HEALTH_ORDERED_PAIRS: tuple[tuple[str, str, str], ...] = (
2609
+ # (warn_field, crit_field, direction) — "asc": crit must be >= warn.
2610
+ ("cargo_target_warn_gb", "cargo_target_crit_gb", "asc"),
2611
+ ("graph_stale_warn_hours", "graph_stale_crit_hours", "asc"),
2612
+ ("plan_usage_warn_pct", "plan_usage_crit_pct", "asc"),
2613
+ ("worktree_warn_count", "worktree_crit_count", "asc"),
2614
+ ("agent_version_warn_behind", "agent_version_crit_behind", "asc"),
2615
+ # Disk thresholds are *headroom* percentages, so crit must be the lower
2616
+ # number: warn at 15% free, crit at 7% free.
2617
+ ("disk_warn_free_pct", "disk_crit_free_pct", "desc"),
2618
+ )
2619
+
2620
+
2621
+ def _parse_health(raw: Any) -> HealthConfig:
2622
+ """Parse the optional ``health:`` block from coordinator.yml (#1628).
2623
+
2624
+ An absent block returns :class:`HealthConfig` defaults — the thresholds
2625
+ that would have fired on the 2026-07-30 incidents.
2626
+ """
2627
+ if raw is None:
2628
+ return HealthConfig()
2629
+ if not isinstance(raw, dict):
2630
+ raise ConfigError("'health' must be a mapping")
2631
+
2632
+ cfg = HealthConfig()
2633
+ known = {f.name for f in fields(HealthConfig)}
2634
+ unknown = sorted(set(raw) - known)
2635
+ if unknown:
2636
+ raise ConfigError(
2637
+ f"unknown health option(s): {', '.join(unknown)} "
2638
+ f"(valid: {', '.join(sorted(known))})"
2639
+ )
2640
+
2641
+ if "enabled" in raw:
2642
+ if not isinstance(raw["enabled"], bool):
2643
+ raise ConfigError("health.enabled must be a boolean")
2644
+ cfg.enabled = raw["enabled"]
2645
+
2646
+ for key in _HEALTH_OPT_STR_FIELDS:
2647
+ if key in raw:
2648
+ value = raw[key]
2649
+ if value is not None and not isinstance(value, str):
2650
+ raise ConfigError(f"health.{key} must be a string or null")
2651
+ # An empty/whitespace string is an operator typo, not "disabled":
2652
+ # accepting it would silently resolve the lane to the CWD.
2653
+ if isinstance(value, str) and not value.strip():
2654
+ raise ConfigError(f"health.{key} must be a non-empty string or null")
2655
+ setattr(cfg, key, value.strip() if isinstance(value, str) else None)
2656
+
2657
+ if "pypi_index_url" in raw:
2658
+ value = raw["pypi_index_url"]
2659
+ if not isinstance(value, str) or not value.strip():
2660
+ raise ConfigError("health.pypi_index_url must be a non-empty string")
2661
+ # Canonicalise the trailing slash here so the value that ends up in
2662
+ # the check's reported `values["index_url"]` is the same string
2663
+ # whichever way the operator wrote it.
2664
+ cfg.pypi_index_url = value.strip().rstrip("/")
2665
+
2666
+ for key in _HEALTH_STR_LIST_FIELDS:
2667
+ if key in raw:
2668
+ value = raw[key]
2669
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
2670
+ raise ConfigError(f"health.{key} must be a list of strings")
2671
+ setattr(cfg, key, list(value))
2672
+
2673
+ for key, minimum in _HEALTH_FLOAT_FIELDS.items():
2674
+ if key in raw:
2675
+ value = raw[key]
2676
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
2677
+ raise ConfigError(f"health.{key} must be a number")
2678
+ if value < minimum:
2679
+ raise ConfigError(f"health.{key} must be >= {minimum}")
2680
+ if key.endswith("_pct") and value > 100:
2681
+ raise ConfigError(f"health.{key} must be between 0 and 100")
2682
+ setattr(cfg, key, float(value))
2683
+
2684
+ for key in _HEALTH_INT_FIELDS:
2685
+ if key in raw:
2686
+ value = raw[key]
2687
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
2688
+ raise ConfigError(f"health.{key} must be a non-negative integer")
2689
+ setattr(cfg, key, value)
2690
+
2691
+ for warn_key, crit_key, direction in _HEALTH_ORDERED_PAIRS:
2692
+ warn_value = getattr(cfg, warn_key)
2693
+ crit_value = getattr(cfg, crit_key)
2694
+ inverted = crit_value < warn_value if direction == "asc" else crit_value > warn_value
2695
+ if inverted:
2696
+ relation = ">=" if direction == "asc" else "<="
2697
+ raise ConfigError(
2698
+ f"health.{crit_key} ({crit_value}) must be {relation} "
2699
+ f"health.{warn_key} ({warn_value}) — otherwise the crit level "
2700
+ f"is unreachable and a failing machine only ever reports WARN"
2701
+ )
2702
+
2703
+ return cfg
2704
+
2705
+
2706
+ def _parse_ci_store(raw: Any) -> CiStoreConfig:
2707
+ if raw is None:
2708
+ return CiStoreConfig()
2709
+ if not isinstance(raw, dict):
2710
+ raise ConfigError("'ci_store' must be a mapping")
2711
+
2712
+ cfg = CiStoreConfig()
2713
+ if "type" in raw:
2714
+ value = raw["type"]
2715
+ if not isinstance(value, str) or value not in ("github", "none"):
2716
+ raise ConfigError("ci_store.type must be one of: github, none")
2717
+ cfg.type = value
2718
+ return cfg
2719
+
2720
+
2721
+ def _parse_merge(raw: Any) -> MergeConfig:
2722
+ """Parse the optional ``merge:`` block from coordinator.yml.
2723
+
2724
+ An absent block returns ``MergeConfig()`` — ``auto_drain=False`` —
2725
+ preserving existing behaviour: the daemon never merges automatically.
2726
+ """
2727
+ if raw is None:
2728
+ return MergeConfig()
2729
+ if not isinstance(raw, dict):
2730
+ raise ConfigError("'merge' must be a mapping")
2731
+
2732
+ cfg = MergeConfig()
2733
+ if "auto_drain" in raw:
2734
+ value = raw["auto_drain"]
2735
+ if not isinstance(value, bool):
2736
+ raise ConfigError("merge.auto_drain must be a boolean")
2737
+ cfg.auto_drain = value
2738
+ if "max_per_tick" in raw:
2739
+ value = raw["max_per_tick"]
2740
+ if not isinstance(value, int) or value < 0:
2741
+ raise ConfigError("merge.max_per_tick must be a non-negative integer")
2742
+ cfg.max_per_tick = value
2743
+ if "auto_reap_merged" in raw:
2744
+ value = raw["auto_reap_merged"]
2745
+ if not isinstance(value, bool):
2746
+ raise ConfigError("merge.auto_reap_merged must be a boolean")
2747
+ cfg.auto_reap_merged = value
2748
+ if "sibling_overlap_aging_hours" in raw:
2749
+ value = raw["sibling_overlap_aging_hours"]
2750
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
2751
+ raise ConfigError(
2752
+ "merge.sibling_overlap_aging_hours must be a non-negative number"
2753
+ )
2754
+ cfg.sibling_overlap_aging_hours = float(value)
2755
+ return cfg
2756
+
2757
+
2758
+ def _parse_milestone(raw: Any) -> MilestoneConfig:
2759
+ """Parse the optional ``milestone:`` block from coordinator.yml.
2760
+
2761
+ An absent block returns ``MilestoneConfig()`` — ``auto_dispatch=False`` —
2762
+ preserving existing behaviour: the daemon never auto-drains a milestone's
2763
+ work order; `coord milestone dispatch` still dispatches the ready
2764
+ frontier once per invocation.
2765
+ """
2766
+ if raw is None:
2767
+ return MilestoneConfig()
2768
+ if not isinstance(raw, dict):
2769
+ raise ConfigError("'milestone' must be a mapping")
2770
+
2771
+ cfg = MilestoneConfig()
2772
+ if "auto_dispatch" in raw:
2773
+ value = raw["auto_dispatch"]
2774
+ if not isinstance(value, bool):
2775
+ raise ConfigError("milestone.auto_dispatch must be a boolean")
2776
+ cfg.auto_dispatch = value
2777
+ return cfg
2778
+
2779
+
2780
+ _VALID_AUDIT_LEVELS = ("business", "operational")
2781
+
2782
+
2783
+ def _parse_audit(raw: Any) -> AuditConfig:
2784
+ """Parse the optional ``audit:`` block from coordinator.yml (#1036/#1038).
2785
+
2786
+ An absent block returns ``AuditConfig()`` — ``max_rows=0`` (unlimited)
2787
+ and ``level="operational"`` — preserving existing behaviour:
2788
+ ``coord.audit.record_audit`` never trims and captures both tiers.
2789
+ """
2790
+ if raw is None:
2791
+ return AuditConfig()
2792
+ if not isinstance(raw, dict):
2793
+ raise ConfigError("'audit' must be a mapping")
2794
+
2795
+ cfg = AuditConfig()
2796
+ if "max_rows" in raw:
2797
+ value = raw["max_rows"]
2798
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
2799
+ raise ConfigError("audit.max_rows must be a non-negative integer")
2800
+ cfg.max_rows = value
2801
+ if "level" in raw:
2802
+ value = raw["level"]
2803
+ if not isinstance(value, str) or value not in _VALID_AUDIT_LEVELS:
2804
+ raise ConfigError(
2805
+ f"audit.level must be one of {_VALID_AUDIT_LEVELS!r}, got {value!r}"
2806
+ )
2807
+ cfg.level = value
2808
+ return cfg
2809
+
2810
+
2811
+ _PRICING_RATE_FIELDS = ("input", "output", "cache_read", "cache_creation")
2812
+
2813
+
2814
+ def _parse_pricing(raw: Any) -> PricingConfig:
2815
+ """Parse the optional ``pricing:`` block from coordinator.yml (#1118).
2816
+
2817
+ An absent block returns ``PricingConfig()`` — the built-in sonnet/opus/
2818
+ haiku defaults from :func:`_default_pricing`. Each entry under
2819
+ ``pricing:`` overrides or extends a canonical model key; unspecified
2820
+ rate fields on an *existing* key (e.g. ``opus``) keep the built-in
2821
+ default rather than being zeroed, so an operator can bump just
2822
+ ``pricing.opus.output`` without restating the other three rates. A
2823
+ wholly new model key starts from ``ModelRates()`` (all zero) and is
2824
+ filled in from whatever fields are given.
2825
+ """
2826
+ models = _default_pricing()
2827
+ if raw is None:
2828
+ return PricingConfig(models=models)
2829
+ if not isinstance(raw, dict):
2830
+ raise ConfigError("'pricing' must be a mapping of model name -> rates")
2831
+
2832
+ for model_key, entry in raw.items():
2833
+ if not isinstance(model_key, str) or not model_key:
2834
+ raise ConfigError("pricing keys must be non-empty strings")
2835
+ if not isinstance(entry, dict):
2836
+ raise ConfigError(f"pricing[{model_key!r}] must be a mapping")
2837
+
2838
+ base = models.get(model_key, ModelRates())
2839
+ rates = ModelRates(
2840
+ input=base.input,
2841
+ output=base.output,
2842
+ cache_read=base.cache_read,
2843
+ cache_creation=base.cache_creation,
2844
+ )
2845
+ for rate_field in _PRICING_RATE_FIELDS:
2846
+ if rate_field not in entry:
2847
+ continue
2848
+ value = entry[rate_field]
2849
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or value < 0:
2850
+ raise ConfigError(
2851
+ f"pricing[{model_key!r}].{rate_field} must be a non-negative number"
2852
+ )
2853
+ setattr(rates, rate_field, float(value))
2854
+ models[model_key] = rates
2855
+
2856
+ return PricingConfig(models=models)
2857
+
2858
+
2859
+ _ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}")
2860
+
2861
+
2862
+ def _expand_env_vars(value: str) -> str:
2863
+ """Expand ``${VAR}`` placeholders in *value* using :data:`os.environ`.
2864
+
2865
+ Unset variables are left as-is (e.g. ``${MISSING}`` stays
2866
+ ``"${MISSING}"``). Only ``${VAR}`` syntax is supported — bare ``$VAR``
2867
+ is not expanded.
2868
+ """
2869
+
2870
+ def _replace(m: re.Match) -> str: # type: ignore[type-arg]
2871
+ var = m.group(1)
2872
+ return os.environ.get(var, m.group(0))
2873
+
2874
+ return _ENV_VAR_RE.sub(_replace, value)
2875
+
2876
+
2877
+ def _parse_providers(raw: Any) -> ProvidersConfig:
2878
+ """Parse the optional ``providers:`` block from coordinator.yml.
2879
+
2880
+ An absent block returns ``ProvidersConfig()`` — ``default == "claude"``
2881
+ and an implicit ``"claude"`` definition present. An explicit block may
2882
+ override ``default`` and/or add named definitions. Values in
2883
+ ``definitions[*].env`` undergo ``${VAR}`` expansion against
2884
+ :data:`os.environ`.
2885
+ """
2886
+ if raw is None:
2887
+ return ProvidersConfig()
2888
+ if not isinstance(raw, dict):
2889
+ raise ConfigError("'providers' must be a mapping")
2890
+
2891
+ cfg = ProvidersConfig()
2892
+
2893
+ if "default" in raw:
2894
+ value = raw["default"]
2895
+ if not isinstance(value, str) or not value:
2896
+ raise ConfigError("providers.default must be a non-empty string")
2897
+ cfg.default = value
2898
+
2899
+ defs_raw = raw.get("definitions", {}) or {}
2900
+ if not isinstance(defs_raw, dict):
2901
+ raise ConfigError("providers.definitions must be a mapping")
2902
+
2903
+ for def_name, def_raw in defs_raw.items():
2904
+ if not isinstance(def_name, str):
2905
+ raise ConfigError("providers.definitions keys must be strings")
2906
+ if not isinstance(def_raw, dict):
2907
+ raise ConfigError(
2908
+ f"providers.definitions[{def_name!r}] must be a mapping"
2909
+ )
2910
+
2911
+ ptype = def_raw.get("type")
2912
+ if not ptype or not isinstance(ptype, str):
2913
+ raise ConfigError(
2914
+ f"providers.definitions[{def_name!r}].type is required (string)"
2915
+ )
2916
+
2917
+ binary = def_raw.get("binary")
2918
+ if binary is not None and not isinstance(binary, str):
2919
+ raise ConfigError(
2920
+ f"providers.definitions[{def_name!r}].binary must be a string"
2921
+ )
2922
+
2923
+ model = def_raw.get("model")
2924
+ if model is not None and not isinstance(model, str):
2925
+ raise ConfigError(
2926
+ f"providers.definitions[{def_name!r}].model must be a string"
2927
+ )
2928
+
2929
+ attach_url = def_raw.get("attach_url")
2930
+ if attach_url is not None and not isinstance(attach_url, str):
2931
+ raise ConfigError(
2932
+ f"providers.definitions[{def_name!r}].attach_url must be a string"
2933
+ )
2934
+
2935
+ env_raw = def_raw.get("env", {}) or {}
2936
+ if not isinstance(env_raw, dict):
2937
+ raise ConfigError(
2938
+ f"providers.definitions[{def_name!r}].env must be a mapping"
2939
+ )
2940
+ for k, v in env_raw.items():
2941
+ if not isinstance(k, str) or not isinstance(v, str):
2942
+ raise ConfigError(
2943
+ f"providers.definitions[{def_name!r}].env must map strings to strings"
2944
+ )
2945
+ # Expand ${VAR} in env values.
2946
+ env: dict[str, str] = {k: _expand_env_vars(v) for k, v in env_raw.items()}
2947
+
2948
+ extra_args_raw = def_raw.get("extra_args", []) or []
2949
+ if not isinstance(extra_args_raw, list) or not all(
2950
+ isinstance(a, str) for a in extra_args_raw
2951
+ ):
2952
+ raise ConfigError(
2953
+ f"providers.definitions[{def_name!r}].extra_args must be a list of strings"
2954
+ )
2955
+ extra_args: list[str] = list(extra_args_raw)
2956
+
2957
+ cfg.definitions[def_name] = ProviderDef(
2958
+ type=ptype,
2959
+ binary=binary,
2960
+ model=model,
2961
+ attach_url=attach_url,
2962
+ env=env,
2963
+ extra_args=extra_args,
2964
+ )
2965
+
2966
+ # Belt-and-suspenders: ProvidersConfig.__post_init__ already
2967
+ # materialises the implicit "claude" entry when ProvidersConfig() is
2968
+ # constructed above (line ~904), so this branch is unreachable under
2969
+ # current code. Kept as a guard against future refactors that might
2970
+ # construct ProvidersConfig differently (e.g. via dict-update or
2971
+ # bypassing __post_init__ with object.__new__) — the invariant
2972
+ # "definitions always contains 'claude'" is load-bearing for
2973
+ # resolve_provider_name() callers that look up the definition
2974
+ # without checking presence first.
2975
+ if "claude" not in cfg.definitions:
2976
+ cfg.definitions["claude"] = ProviderDef(type="claude")
2977
+
2978
+ # #1889: providers.labels — an issue-level lever mirroring
2979
+ # models.labels (label name -> provider name), parsed AFTER
2980
+ # cfg.definitions above so it can be validated against the very
2981
+ # registry it references. Validated at parse time, the same pattern
2982
+ # reviews.provider uses (#1811) — an unknown provider name here is a
2983
+ # config-load error, not a dispatch-time surprise discovered at 2am.
2984
+ labels_raw = raw.get("labels", {}) or {}
2985
+ if not isinstance(labels_raw, dict) or not all(
2986
+ isinstance(k, str) and isinstance(v, str) for k, v in labels_raw.items()
2987
+ ):
2988
+ raise ConfigError(
2989
+ "providers.labels must be a mapping of label name → provider name"
2990
+ )
2991
+ unknown_providers = sorted(set(labels_raw.values()) - set(cfg.definitions))
2992
+ if unknown_providers:
2993
+ raise ConfigError(
2994
+ f"providers.labels references unknown provider(s): {unknown_providers}"
2995
+ )
2996
+ cfg.labels = dict(labels_raw)
2997
+
2998
+ return cfg
2999
+
3000
+
3001
+ def _validate_dependencies(repos: list[Repo]) -> None:
3002
+ from coord.deps import detect_cycles
3003
+
3004
+ repo_names = {r.name for r in repos}
3005
+ for r in repos:
3006
+ unknown = [d for d in r.depends_on if d not in repo_names]
3007
+ if unknown:
3008
+ raise ConfigError(
3009
+ f"repo {r.name!r} depends_on unknown repos: {unknown}"
3010
+ )
3011
+ if r.name in r.depends_on:
3012
+ raise ConfigError(f"repo {r.name!r} cannot depend on itself")
3013
+
3014
+ # #268: reference_repos go through the same name-resolution as
3015
+ # depends_on but DO NOT feed into the cycle detector — the
3016
+ # intent is precisely to allow back-references (vimcode →
3017
+ # quadraui in depends_on; quadraui → vimcode in reference_repos)
3018
+ # that would be cycles if treated as build deps.
3019
+ unknown_ref = [r2 for r2 in r.reference_repos if r2 not in repo_names]
3020
+ if unknown_ref:
3021
+ raise ConfigError(
3022
+ f"repo {r.name!r} reference_repos unknown repos: {unknown_ref}"
3023
+ )
3024
+ if r.name in r.reference_repos:
3025
+ raise ConfigError(
3026
+ f"repo {r.name!r} cannot reference itself"
3027
+ )
3028
+
3029
+ cycles = detect_cycles(repos)
3030
+ if cycles:
3031
+ cycle_str = " → ".join(cycles[0])
3032
+ raise ConfigError(f"circular dependency detected: {cycle_str}")