code-coordinator 0.5.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
@@ -0,0 +1,329 @@
1
+ """Shared infra for coord/commands/*.py: config loading, the shared
2
+ ``--config`` option, port constants, and the handful of helpers used by
3
+ more than one command module. Extracted from coord/cli.py (#747).
4
+
5
+ #1237 (PKG-1): this module is imported by *every* command module, including
6
+ the client-clean ones, so it is the one file that absolutely must not reach
7
+ the server stack at import time. Keep it that way:
8
+
9
+ - No module-scope import of ``coord.serve_app`` / ``coord.agent_app`` /
10
+ ``coord.dashboard.*`` / ``starlette`` / ``uvicorn`` / ``psutil``. The port
11
+ constants below are deliberately *duplicated literals* rather than
12
+ re-exports for this reason — importing ``coord.serve_app`` just to read
13
+ ``SERVE_PORT`` would drag starlette into ``coord status``.
14
+ - Server-only work goes behind :func:`server_extra_guard`, which turns the
15
+ resulting ``ModuleNotFoundError`` into an actionable "install the [server]
16
+ extra" message.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import socket
23
+ import sys
24
+ from contextlib import contextmanager
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING
27
+
28
+ import click
29
+
30
+ from coord.config import Config, ConfigError, load, resolve_config_path
31
+
32
+ if TYPE_CHECKING: # pragma: no cover — typing only
33
+ from collections.abc import Iterator
34
+
35
+ # Canonical constant in coord.brain.AGENT_PORT — duplicated here as a literal
36
+ # so the CLI decorator default doesn't have to import the brain (and, through
37
+ # it, the provider stack) on every `coord` invocation.
38
+ AGENT_PORT = 7433
39
+ # Portable control-center daemon port (#584); canonical constant in
40
+ # coord.serve_app.SERVE_PORT — duplicated here for the CLI decorator default,
41
+ # mirroring the AGENT_PORT pattern above.
42
+ SERVE_PORT = 7435
43
+
44
+ #: Distributions provided by the ``[server]`` extra (#1237). A
45
+ #: ``ModuleNotFoundError`` naming any of these from a server codepath means
46
+ #: "base install, no extra" — not a bug.
47
+ SERVER_EXTRA_MODULES = frozenset({"starlette", "uvicorn", "websockets", "psutil"})
48
+
49
+ #: What to tell a user who hit a server command on a client-only install.
50
+ SERVER_EXTRA_INSTALL_HINT = "pip install 'code-coordinator[server]'"
51
+
52
+
53
+ @contextmanager
54
+ def server_extra_guard(feature: str) -> "Iterator[None]":
55
+ """Translate a missing ``[server]`` extra into an actionable CLI error.
56
+
57
+ ``pip install code-coordinator`` installs a *client* (#1237): no
58
+ starlette/uvicorn/websockets/psutil. Wrap the function-local imports that
59
+ boot a server in this so the failure reads as "you need the extra" rather
60
+ than a raw ``ModuleNotFoundError: No module named 'uvicorn'`` traceback::
61
+
62
+ with server_extra_guard("serve"):
63
+ import uvicorn
64
+ from coord.serve_app import build_app
65
+
66
+ *feature* is the user-facing command name (``"serve"``, ``"web"``,
67
+ ``"agent"``) and is echoed back in the message.
68
+
69
+ Anything that is *not* one of the extra's modules re-raises untouched — a
70
+ genuinely broken import inside ``coord.serve_app`` must not be papered
71
+ over as a packaging problem.
72
+ """
73
+ try:
74
+ yield
75
+ except ModuleNotFoundError as exc:
76
+ missing = (exc.name or "").split(".")[0]
77
+ if missing not in SERVER_EXTRA_MODULES:
78
+ raise
79
+ raise click.ClickException(
80
+ f"`coord {feature}` needs the server extra, which is not installed "
81
+ f"(missing {missing!r}).\n"
82
+ f" Install it with: {SERVER_EXTRA_INSTALL_HINT}\n"
83
+ " The base `code-coordinator` install is a client-only CLI — it "
84
+ "can drive a remote fleet, but not host one (#1237)."
85
+ ) from exc
86
+
87
+
88
+ _CONFIG_OPTION = click.option(
89
+ "--config",
90
+ "config_path",
91
+ type=click.Path(path_type=Path),
92
+ # Callable default: resolved per-invocation to a concrete Path so every
93
+ # command receives a real path (never None). Resolution order:
94
+ # $COORD_CONFIG → ~/.coord/coordinator.yml → ./coordinator.yml.
95
+ default=resolve_config_path,
96
+ help=(
97
+ "Path to coordinator.yml. Default resolution: $COORD_CONFIG, then "
98
+ "~/.coord/coordinator.yml, then ./coordinator.yml."
99
+ ),
100
+ )
101
+
102
+
103
+ def _save_config_snapshot(config: Config) -> None:
104
+ """Persist machine + pipeline metadata to the DB so dashboards can read it.
105
+
106
+ Writes:
107
+ - ``machines`` rows (used by the web dashboard + the TUI Machines view)
108
+ - ``board_meta['pipeline_default_gates']`` JSON list of default gates
109
+ - ``board_meta['pipeline_tracked_labels']`` JSON list of tracked GitHub
110
+ issue labels (defaults to ``['coord']`` when unconfigured)
111
+
112
+ The pipeline keys let the TUI Pipeline panel pick up coordinator.yml
113
+ settings without having to parse YAML itself.
114
+ """
115
+ # #584: a thin client (board_service configured) must not create/write a
116
+ # local DB — the daemon/host owns the config snapshot. On the host
117
+ # board_service is unset, so the snapshot is written as before.
118
+ from coord.client import resolve_board_service
119
+ if resolve_board_service() is not None:
120
+ return
121
+ conn = None
122
+ try:
123
+ from coord.db import get_connection
124
+ conn = get_connection()
125
+ conn.execute("DELETE FROM machines")
126
+ for m in config.machines:
127
+ conn.execute(
128
+ "INSERT INTO machines (name, host, capabilities, repos) VALUES (?, ?, ?, ?)",
129
+ (m.name, m.host, json.dumps(m.capabilities), json.dumps(m.repos)),
130
+ )
131
+ conn.execute(
132
+ "INSERT OR REPLACE INTO board_meta (key, value) VALUES "
133
+ "('pipeline_default_gates', ?)",
134
+ (json.dumps(list(config.pipeline.default_gates)),),
135
+ )
136
+ conn.execute(
137
+ "INSERT OR REPLACE INTO board_meta (key, value) VALUES "
138
+ "('pipeline_tracked_labels', ?)",
139
+ (json.dumps(config.pipeline.tracked_labels()),),
140
+ )
141
+ # Repo name → GitHub slug map: the TUI pipeline panel uses this to
142
+ # translate a `gh search issues` repository.nameWithOwner back into
143
+ # the coord-local repo name expected by `coord assign`.
144
+ conn.execute(
145
+ "INSERT OR REPLACE INTO board_meta (key, value) VALUES "
146
+ "('pipeline_repos', ?)",
147
+ (json.dumps({r.name: r.github for r in config.repos}),),
148
+ )
149
+ # #296: run_cmd per repo — TUI surfaces this in the Test stage
150
+ # detail panel as the "Run" row so the tester knows what to launch.
151
+ # Only repos that have a run_cmd are included; absent → no entry.
152
+ conn.execute(
153
+ "INSERT OR REPLACE INTO board_meta (key, value) VALUES "
154
+ "('pipeline_repo_run_cmds', ?)",
155
+ (json.dumps({r.name: r.run_cmd for r in config.repos if r.run_cmd is not None}),),
156
+ )
157
+ # Whether the pipeline includes a Plan gate before Work. Sourced
158
+ # from dispatch.require_plan — when true, the TUI prepends a Plan
159
+ # stage and Work [Go] becomes "approve plan" rather than fresh
160
+ # dispatch.
161
+ conn.execute(
162
+ "INSERT OR REPLACE INTO board_meta (key, value) VALUES "
163
+ "('pipeline_require_plan', ?)",
164
+ ("1" if config.dispatch.require_plan else "0",),
165
+ )
166
+ # #803: models config snapshot — TUI reads this to show which model
167
+ # tier will be used for an interactive --fix-of without needing to
168
+ # parse coordinator.yml itself.
169
+ conn.execute(
170
+ "INSERT OR REPLACE INTO board_meta (key, value) VALUES "
171
+ "('pipeline_models', ?)",
172
+ (json.dumps({
173
+ "default": config.models.default,
174
+ "escalation": config.models.escalation,
175
+ "escalate_fix_model": config.pipeline.escalate_fix_model,
176
+ }),),
177
+ )
178
+ # #349: repo_name → local-checkout path for the machine running this
179
+ # coordinator. Used by the TUI to read git branch HEADs when
180
+ # detecting test-plan staleness. Only includes repos that have a
181
+ # repo_paths entry on the matching machine (hostname-matched first;
182
+ # any machine as fallback).
183
+ local_hostname = socket.gethostname().split(".")[0]
184
+ repo_paths_map: dict[str, str] = {}
185
+ # Try hostname-matched machine first, then fall back to all machines.
186
+ for pass_no in range(2):
187
+ for m in config.machines:
188
+ on_this_machine = (
189
+ m.name == local_hostname
190
+ or m.host.split(".")[0] == local_hostname
191
+ )
192
+ if pass_no == 0 and not on_this_machine:
193
+ continue
194
+ for rn in m.repos:
195
+ if rn not in repo_paths_map:
196
+ p = m.repo_path(rn)
197
+ if p:
198
+ repo_paths_map[rn] = str(Path(p).expanduser())
199
+ conn.execute(
200
+ "INSERT OR REPLACE INTO board_meta (key, value) VALUES "
201
+ "('pipeline_repo_paths', ?)",
202
+ (json.dumps(repo_paths_map),),
203
+ )
204
+ # #1151: repo_name -> route `match` globs, for repos whose acceptance
205
+ # driver is *routed* (acceptance.drivers.<repo>.routes non-empty,
206
+ # #1125). Unrouted repos (flat driver or no driver at all) are
207
+ # omitted entirely. The TUI's Pipeline right-click acceptance actions
208
+ # (`dispatch_gate_a_mock_for_selected_pipeline_row` /
209
+ # `dispatch_acceptance_author_for_selected_pipeline_row` /
210
+ # `dispatch_acceptance_record_for_selected_pipeline_row`, all in
211
+ # tui/src/app/pipeline.rs) were firing `coord acceptance mock/author/
212
+ # record` with no `--for-path`, which those CLI commands reject with
213
+ # "no route matched" the moment a repo's driver becomes routed. This
214
+ # lets the TUI auto-resolve the unambiguous (single-route) case and
215
+ # surface a clear, actionable warning — instead of a raw CLI error —
216
+ # when more than one route exists and it can't tell which applies.
217
+ acceptance_routes_map: dict[str, list[str]] = {
218
+ repo_name: [route.match for route in driver.routes]
219
+ for repo_name, driver in config.acceptance.drivers.items()
220
+ if driver.routes
221
+ }
222
+ conn.execute(
223
+ "INSERT OR REPLACE INTO board_meta (key, value) VALUES "
224
+ "('pipeline_acceptance_routes', ?)",
225
+ (json.dumps(acceptance_routes_map),),
226
+ )
227
+ conn.commit()
228
+ except Exception: # noqa: BLE001 — non-critical, don't abort CLI
229
+ if conn is not None:
230
+ try:
231
+ conn.rollback()
232
+ except Exception: # noqa: BLE001
233
+ pass
234
+
235
+
236
+ def _load_config(path: Path | None) -> Config:
237
+ # Resolve the default location ($COORD_CONFIG → ~/.coord/coordinator.yml →
238
+ # ./coordinator.yml) when no explicit --config was given, so `coord` works on
239
+ # a machine without a repo checkout and isn't sensitive to the CWD.
240
+ if path is None:
241
+ from coord.config import resolve_config_path # noqa: PLC0415
242
+
243
+ path = resolve_config_path()
244
+ # #1080: "am I a thin client" (board_service configured) is the PRIMARY
245
+ # branch, checked before "does a local file exist" — not after. A thin
246
+ # client must never trust a local coordinator.yml, even one that happens
247
+ # to exist: a stray ~/.coord/coordinator.yml or ./coordinator.yml can
248
+ # silently diverge from the daemon's real config with no signal that it's
249
+ # stale (#947 friction log — a 7-week-old symlink shadowed the daemon's
250
+ # config on every command). On a machine with no client.toml/board_service
251
+ # (svc is None — e.g. the daemon host), this is a no-op and local-file
252
+ # resolution proceeds exactly as before (#584/#591).
253
+ try:
254
+ from coord.client import resolve_board_service # noqa: PLC0415
255
+
256
+ svc = resolve_board_service()
257
+ if svc is not None:
258
+ from coord.client import fetch_remote_config # noqa: PLC0415
259
+
260
+ try:
261
+ path = fetch_remote_config(svc)
262
+ except Exception as exc: # noqa: BLE001 — do NOT fall through to
263
+ # load(path): path may point at a local file that happens to
264
+ # exist (the exact bypass this issue closes). Fail loudly
265
+ # instead of silently trusting whatever is on disk.
266
+ raise ConfigError(
267
+ f"could not fetch config from {svc.url}: {exc}"
268
+ ) from exc
269
+ cfg = load(path)
270
+ except ConfigError as e:
271
+ click.echo(f"error: {e}", err=True)
272
+ sys.exit(2)
273
+ _save_config_snapshot(cfg)
274
+ return cfg
275
+
276
+
277
+ def _not_implemented(name: str) -> None:
278
+ click.echo(f"coord {name}: not implemented yet (stub)", err=True)
279
+ sys.exit(1)
280
+
281
+
282
+ def _apply_label_change(
283
+ repo: str,
284
+ issue: int,
285
+ config_path: Path,
286
+ *,
287
+ add: set[str],
288
+ remove_if_present: set[str],
289
+ success_message: str,
290
+ no_op_message: str | None = None,
291
+ ) -> None:
292
+ """Shared backbone for the lifecycle label-change commands
293
+ (#260/#261/#266/#802).
294
+
295
+ Resolves *repo* via ``coordinator.yml``, then delegates to
296
+ ``state.apply_issue_labels`` which routes through the daemon seam
297
+ (GitHub via ``gh`` today; GitLab / bare-DB later) — the same seam
298
+ ``coord issue label`` uses. The local ``issues`` cache is updated
299
+ inside the seam so the TUI reflects the change on its next tick.
300
+
301
+ ``no_op_message`` (optional) is echoed when no labels were actually
302
+ added or removed — used by ``coord backlog`` to say "already in
303
+ Backlog" instead of making a no-op ``gh`` call.
304
+ """
305
+ from coord.state import apply_issue_labels # noqa: PLC0415
306
+
307
+ cfg = _load_config(config_path)
308
+ repo_entry = cfg.repo(repo)
309
+ if repo_entry is None:
310
+ click.echo(f"error: unknown repo {repo!r} (not in coordinator.yml)", err=True)
311
+ sys.exit(1)
312
+ slug = repo_entry.github
313
+
314
+ try:
315
+ _new_labels, changed = apply_issue_labels(
316
+ repo, issue,
317
+ add=add,
318
+ remove=remove_if_present,
319
+ repo_github=slug,
320
+ )
321
+ except Exception as e: # noqa: BLE001
322
+ click.echo(f"error: label change failed: {e}", err=True)
323
+ sys.exit(1)
324
+
325
+ if not changed and no_op_message is not None:
326
+ click.echo(no_op_message)
327
+ return
328
+
329
+ click.echo(success_message)