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,681 @@
1
+ """`coord sync` plus the `issue`/`context` groups and `track`/`untrack`/
2
+ `backlog`. Extracted from coord/cli.py (#747)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from coord.commands._common import _apply_label_change, _CONFIG_OPTION, _load_config
12
+
13
+
14
+ @click.command(help="Sync open issues from GitHub into the local SQLite cache.")
15
+ @_CONFIG_OPTION
16
+ @click.option("--quiet", "-q", is_flag=True, help="Suppress per-repo output.")
17
+ def sync(config_path: Path, quiet: bool) -> None:
18
+ """Fetch open issues for every configured repo and write them to the local
19
+ ``issues`` table in ``~/.coord/coord.db``.
20
+
21
+ The TUI board reads from this table to show the full backlog under
22
+ Pending. Run this manually, call it from a cron job, or press 'r' in
23
+ the TUI which triggers it automatically alongside the data refresh.
24
+ """
25
+ from coord import github_ops
26
+ from coord.state import (
27
+ list_issue_numbers_with_assignments,
28
+ sync_issue_comments,
29
+ upsert_open_issues,
30
+ )
31
+
32
+ cfg = _load_config(config_path)
33
+ total = 0
34
+ for repo in cfg.repos:
35
+ try:
36
+ issues = github_ops.get_open_issues(repo.github)
37
+ upsert_open_issues(repo.name, issues)
38
+ if not quiet:
39
+ click.echo(f" {repo.name}: {len(issues)} open issue(s)")
40
+ total += len(issues)
41
+ except Exception as e: # noqa: BLE001
42
+ click.echo(f" {repo.name}: sync failed — {e}", err=True)
43
+ continue
44
+
45
+ # #873: opportunistically backfill the durable issue_comments mirror
46
+ # for issues coord has actually dispatched work on — scoped (not a
47
+ # crawl of every open issue) to keep this bounded on every sync.
48
+ assigned_numbers = list_issue_numbers_with_assignments(repo.name)
49
+ open_numbers = {i["number"] for i in issues}
50
+ comments_synced = 0
51
+ for number in sorted(assigned_numbers & open_numbers):
52
+ try:
53
+ comments_synced += sync_issue_comments(
54
+ repo.name, number, repo_github=repo.github
55
+ )
56
+ except Exception: # noqa: BLE001 — best-effort, never fails `coord sync`
57
+ pass
58
+ if not quiet and assigned_numbers & open_numbers:
59
+ click.echo(
60
+ f" {repo.name}: {comments_synced} comment(s) synced across "
61
+ f"{len(assigned_numbers & open_numbers)} assigned issue(s)"
62
+ )
63
+ if not quiet:
64
+ click.echo(f"synced {total} open issue(s) across {len(cfg.repos)} repo(s)")
65
+
66
+
67
+ @click.group("issue")
68
+ def issue_group() -> None:
69
+ """Issue-tracker operations through the backend-agnostic seam.
70
+
71
+ The write routes through the daemon (GitHub via `gh` today; GitLab /
72
+ bare-DB later) so callers — notably the chat-about-issue session — never
73
+ touch `gh` directly.
74
+ """
75
+
76
+
77
+ @issue_group.command(
78
+ "edit",
79
+ help=(
80
+ "Edit an issue's title and/or body. REPO is the local repo name from "
81
+ "coordinator.yml; ISSUE is the GH issue number. Provide --title and/or "
82
+ "--body / --body-file. Routes through the issue-tracker seam."
83
+ ),
84
+ )
85
+
86
+
87
+ @click.argument("repo")
88
+ @click.argument("issue", type=int)
89
+ @click.option("--title", default=None, help="New issue title.")
90
+ @click.option("--body", default=None, help="New issue body (markdown).")
91
+ @click.option(
92
+ "--body-file",
93
+ type=click.Path(path_type=Path),
94
+ default=None,
95
+ help="Read the new body from a file (preferred for long markdown). '-' = stdin.",
96
+ )
97
+
98
+
99
+ @_CONFIG_OPTION
100
+ def issue_edit_cmd(
101
+ repo: str,
102
+ issue: int,
103
+ title: str | None,
104
+ body: str | None,
105
+ body_file: Path | None,
106
+ config_path: Path,
107
+ ) -> None:
108
+ cfg = _load_config(config_path)
109
+ repo_entry = cfg.repo(repo)
110
+ slug = repo_entry.github if repo_entry else repo
111
+ if body_file is not None:
112
+ body = sys.stdin.read() if str(body_file) == "-" else Path(body_file).read_text()
113
+ if title is None and body is None:
114
+ click.echo("error: provide --title and/or --body / --body-file", err=True)
115
+ sys.exit(2)
116
+ from coord.state import edit_issue_content # noqa: PLC0415
117
+
118
+ try:
119
+ updated = edit_issue_content(
120
+ repo, issue, title=title, body=body, repo_github=slug
121
+ )
122
+ except Exception as e: # noqa: BLE001
123
+ click.echo(f"error: issue edit failed: {e}", err=True)
124
+ sys.exit(1)
125
+ click.echo(f"#{issue} ({slug}) updated" if updated else f"#{issue} ({slug}): no change")
126
+
127
+
128
+ @issue_group.command(
129
+ "close",
130
+ help=(
131
+ "Close an issue, optionally posting --comment first (#1003). REPO "
132
+ "is the local repo name from coordinator.yml; ISSUE is the GH issue "
133
+ "number. The Plans-panel \"Close / archive plan\" action's backend "
134
+ "— thin wrapper around the already-existing "
135
+ "``github_ops.close_issue()`` (previously only called internally by "
136
+ "``coord merge``), now operator-exposed through the tracker seam. "
137
+ "Idempotent — closing an already-closed issue is a no-op. "
138
+ "#1196: refuses (exit 1, clear message) when the issue still has "
139
+ "open children — an epic must not read as \"done\" while its "
140
+ "sub-issues are open/unstarted. Pass --force to override, "
141
+ "mirroring `coord merge --force-merge`."
142
+ ),
143
+ )
144
+ @click.argument("repo")
145
+ @click.argument("issue", type=int)
146
+ @click.option(
147
+ "--comment", default=None, help="Comment to post before closing (markdown)."
148
+ )
149
+ @click.option(
150
+ "--force", is_flag=True, default=False,
151
+ help="Close even if the issue has open children (#1196).",
152
+ )
153
+ @_CONFIG_OPTION
154
+ def issue_close_cmd(
155
+ repo: str,
156
+ issue: int,
157
+ comment: str | None,
158
+ force: bool,
159
+ config_path: Path,
160
+ ) -> None:
161
+ cfg = _load_config(config_path)
162
+ repo_entry = cfg.repo(repo)
163
+ slug = repo_entry.github if repo_entry else repo
164
+ from coord.state import close_issue # noqa: PLC0415
165
+
166
+ try:
167
+ close_issue(repo, issue, comment=comment, repo_github=slug, force=force)
168
+ except Exception as e: # noqa: BLE001
169
+ click.echo(f"error: issue close failed: {e}", err=True)
170
+ sys.exit(1)
171
+ click.echo(f"#{issue} ({slug}) closed")
172
+
173
+
174
+ @issue_group.command(
175
+ "reopen",
176
+ help=(
177
+ "Reopen a closed issue, optionally posting --comment first (#1078). "
178
+ "REPO is the local repo name from coordinator.yml; ISSUE is the GH "
179
+ "issue number. Mirror of the `close` command for the complement "
180
+ "operation. Idempotent — reopening an already-open issue is a no-op."
181
+ ),
182
+ )
183
+ @click.argument("repo")
184
+ @click.argument("issue", type=int)
185
+ @click.option(
186
+ "--comment", default=None, help="Comment to post before reopening (markdown)."
187
+ )
188
+ @_CONFIG_OPTION
189
+ def issue_reopen_cmd(
190
+ repo: str,
191
+ issue: int,
192
+ comment: str | None,
193
+ config_path: Path,
194
+ ) -> None:
195
+ cfg = _load_config(config_path)
196
+ repo_entry = cfg.repo(repo)
197
+ slug = repo_entry.github if repo_entry else repo
198
+ from coord.state import reopen_issue # noqa: PLC0415
199
+
200
+ try:
201
+ reopen_issue(repo, issue, comment=comment, repo_github=slug)
202
+ except Exception as e: # noqa: BLE001
203
+ click.echo(f"error: issue reopen failed: {e}", err=True)
204
+ sys.exit(1)
205
+ click.echo(f"#{issue} ({slug}) reopened")
206
+
207
+
208
+ @issue_group.command(
209
+ "create",
210
+ help=(
211
+ "Create a new GitHub issue through the backend-agnostic seam. REPO "
212
+ "is the local repo name from coordinator.yml. Prints the new issue "
213
+ "number on success.\n\n"
214
+ "Use --body-file for long markdown bodies (avoids shell-quoting "
215
+ "issues). '-' reads from stdin. Routes through the daemon seam so "
216
+ "agents never need to call `gh issue create` directly."
217
+ ),
218
+ )
219
+ @click.argument("repo")
220
+ @click.option("--title", required=True, help="Issue title.")
221
+ @click.option("--body", default=None, help="Issue body (markdown).")
222
+ @click.option(
223
+ "--body-file",
224
+ type=click.Path(path_type=Path),
225
+ default=None,
226
+ help="Read the body from a file. '-' = stdin.",
227
+ )
228
+ @click.option(
229
+ "--label",
230
+ "labels",
231
+ multiple=True,
232
+ help="Label to add (repeatable). The label must already exist in the repo.",
233
+ )
234
+ @_CONFIG_OPTION
235
+ def issue_create_cmd(
236
+ repo: str,
237
+ title: str,
238
+ body: str | None,
239
+ body_file: Path | None,
240
+ labels: tuple[str, ...],
241
+ config_path: Path,
242
+ ) -> None:
243
+ cfg = _load_config(config_path)
244
+ repo_entry = cfg.repo(repo)
245
+ slug = repo_entry.github if repo_entry else repo
246
+ if body_file is not None:
247
+ body = sys.stdin.read() if str(body_file) == "-" else Path(body_file).read_text()
248
+ from coord.state import create_issue as _create_issue # noqa: PLC0415
249
+
250
+ try:
251
+ result = _create_issue(
252
+ repo, title, body or "",
253
+ labels=list(labels),
254
+ repo_github=slug,
255
+ )
256
+ except Exception as e: # noqa: BLE001
257
+ click.echo(f"error: issue create failed: {e}", err=True)
258
+ sys.exit(1)
259
+ click.echo(f"#{result['number']} ({slug}) created")
260
+
261
+
262
+ @issue_group.command(
263
+ "label",
264
+ help=(
265
+ "Add and/or remove arbitrary labels on an existing issue through the "
266
+ "backend-agnostic seam. REPO is the local repo name from "
267
+ "coordinator.yml; ISSUE is the GH issue number.\n\n"
268
+ "Provide --add and/or --remove (both repeatable). Already-present "
269
+ "labels in --add and already-absent labels in --remove are "
270
+ "silently ignored (idempotent). Updates the local issues cache so "
271
+ "the TUI reflects the change without waiting for `coord sync`.\n\n"
272
+ "Routes through the daemon seam so agents never need to call "
273
+ "`gh issue edit` directly."
274
+ ),
275
+ )
276
+ @click.argument("repo")
277
+ @click.argument("issue", type=int)
278
+ @click.option(
279
+ "--add",
280
+ "add_labels",
281
+ multiple=True,
282
+ help="Label to add (repeatable).",
283
+ )
284
+ @click.option(
285
+ "--remove",
286
+ "remove_labels",
287
+ multiple=True,
288
+ help="Label to remove (repeatable).",
289
+ )
290
+ @_CONFIG_OPTION
291
+ def issue_label_cmd(
292
+ repo: str,
293
+ issue: int,
294
+ add_labels: tuple[str, ...],
295
+ remove_labels: tuple[str, ...],
296
+ config_path: Path,
297
+ ) -> None:
298
+ if not add_labels and not remove_labels:
299
+ click.echo("error: provide --add and/or --remove", err=True)
300
+ sys.exit(2)
301
+ from coord.state import apply_issue_labels, get_cached_issue_labels # noqa: PLC0415
302
+
303
+ cfg = _load_config(config_path)
304
+ repo_entry = cfg.repo(repo)
305
+ slug = repo_entry.github if repo_entry else repo
306
+
307
+ # Snapshot the pre-change cache so the echoed message reflects the actual
308
+ # delta, not just the requested --add/--remove sets (a requested label
309
+ # that was already present/absent is a no-op for it specifically, even
310
+ # when other labels in the same call do change something).
311
+ old_labels = get_cached_issue_labels(repo, issue)
312
+ try:
313
+ new_labels, changed = apply_issue_labels(
314
+ repo, issue,
315
+ add=set(add_labels),
316
+ remove=set(remove_labels),
317
+ repo_github=slug,
318
+ )
319
+ except Exception as e: # noqa: BLE001
320
+ click.echo(f"error: issue label failed: {e}", err=True)
321
+ sys.exit(1)
322
+ if changed:
323
+ if old_labels is not None:
324
+ actually_added = sorted(set(add_labels) & (set(new_labels) - set(old_labels)))
325
+ actually_removed = sorted(set(remove_labels) & (set(old_labels) - set(new_labels)))
326
+ else:
327
+ # No prior cache snapshot to diff against (issue not yet synced
328
+ # locally) — fall back to the requested sets.
329
+ actually_added = sorted(add_labels)
330
+ actually_removed = sorted(remove_labels)
331
+ parts: list[str] = []
332
+ if actually_added:
333
+ parts.append(f"+{{{', '.join(actually_added)}}}")
334
+ if actually_removed:
335
+ parts.append(f"-{{{', '.join(actually_removed)}}}")
336
+ if not parts:
337
+ # Stale local cache made the diff a no-op even though the seam
338
+ # reported a real change upstream — fall back to the full
339
+ # resulting label set rather than echoing a blank delta.
340
+ parts.append(f"(now: {{{', '.join(sorted(new_labels))}}})")
341
+ click.echo(f"#{issue} ({slug}) labels updated: {' '.join(parts)}")
342
+ else:
343
+ click.echo(f"#{issue} ({slug}) labels unchanged (no delta)")
344
+
345
+
346
+ @click.group("context")
347
+ def context_group() -> None:
348
+ """The per-issue rolling context digest (#603).
349
+
350
+ Short, curated notes (cross-repo deps, approaches already tried, hard
351
+ constraints) injected at the TOP of every agent briefing for the issue so
352
+ findings don't evaporate between attempts. DB-only, dropped when the issue
353
+ closes. Pinned entries stay on top and never age out.
354
+ """
355
+
356
+
357
+ @context_group.command("show")
358
+ @click.argument("repo")
359
+ @click.argument("issue", type=int)
360
+ @_CONFIG_OPTION
361
+ def context_show(repo: str, issue: int, config_path: Path) -> None:
362
+ """Print the rendered digest plus raw entries (with ids for pin/clear)."""
363
+ from coord.state import list_issue_context, render_issue_context_entries
364
+
365
+ entries = list_issue_context(repo, issue)
366
+ if not entries:
367
+ click.echo(f"(no context for {repo} #{issue})")
368
+ return
369
+ click.echo(render_issue_context_entries(entries))
370
+ click.echo("\nentries (id · source · pinned):")
371
+ for e in entries:
372
+ pin = "📌" if e["pinned"] else " "
373
+ src = f" [{e['source']}]" if e.get("source") else ""
374
+ click.echo(f" {pin} #{e['id']}{src}: {e['body']}")
375
+
376
+
377
+ @context_group.command("add")
378
+ @click.argument("repo")
379
+ @click.argument("issue", type=int)
380
+ @click.argument("body")
381
+ @click.option(
382
+ "--pin", "pinned", is_flag=True,
383
+ help="Pin as a critical (always on top, never aged out by the budget).",
384
+ )
385
+
386
+
387
+ @click.option(
388
+ "--source", default="operator",
389
+ help="Who recorded this: work|fix|review|test|operator (default operator).",
390
+ )
391
+
392
+
393
+ @_CONFIG_OPTION
394
+ def context_add(
395
+ repo: str, issue: int, body: str, pinned: bool, source: str, config_path: Path
396
+ ) -> None:
397
+ """Append a context entry for REPO #ISSUE (BODY is one short finding)."""
398
+ from coord.state import add_issue_context_entry
399
+
400
+ eid = add_issue_context_entry(repo, issue, body, pinned=pinned, source=source)
401
+ tag = " (pinned)" if pinned else ""
402
+ suffix = f" (id {eid})" if eid else ""
403
+ click.echo(f"added{tag} to {repo} #{issue}{suffix}")
404
+
405
+
406
+ @context_group.command("pin")
407
+ @click.argument("repo")
408
+ @click.argument("issue", type=int)
409
+ @click.argument("entry_id", type=int)
410
+ @_CONFIG_OPTION
411
+ def context_pin(repo: str, issue: int, entry_id: int, config_path: Path) -> None:
412
+ """Pin entry ENTRY_ID so it stays on top and never ages out."""
413
+ from coord.state import set_issue_context_pin
414
+
415
+ click.echo("pinned" if set_issue_context_pin(repo, issue, entry_id, True) else "no such entry")
416
+
417
+
418
+ @context_group.command("unpin")
419
+ @click.argument("repo")
420
+ @click.argument("issue", type=int)
421
+ @click.argument("entry_id", type=int)
422
+ @_CONFIG_OPTION
423
+ def context_unpin(repo: str, issue: int, entry_id: int, config_path: Path) -> None:
424
+ """Unpin entry ENTRY_ID (it becomes a normal aged-out note)."""
425
+ from coord.state import set_issue_context_pin
426
+
427
+ click.echo("unpinned" if set_issue_context_pin(repo, issue, entry_id, False) else "no such entry")
428
+
429
+
430
+ @context_group.command("clear")
431
+ @click.argument("repo")
432
+ @click.argument("issue", type=int)
433
+ @_CONFIG_OPTION
434
+ def context_clear(repo: str, issue: int, config_path: Path) -> None:
435
+ """Delete ALL context entries for REPO #ISSUE."""
436
+ from coord.state import clear_issue_context
437
+
438
+ n = clear_issue_context(repo, issue)
439
+ click.echo(f"cleared {n} entr{'y' if n == 1 else 'ies'} for {repo} #{issue}")
440
+
441
+
442
+ @context_group.command("curate")
443
+ @click.argument("repo")
444
+ @click.argument("issue", type=int)
445
+ @click.option(
446
+ "--model", default="haiku",
447
+ help="claude -p model for the compress (default haiku — cheap).",
448
+ )
449
+
450
+
451
+ @_CONFIG_OPTION
452
+ def context_curate(repo: str, issue: int, model: str, config_path: Path) -> None:
453
+ """LLM-compress the digest: merge duplicates, drop resolved notes, keep
454
+ pinned criticals. On-demand (one metered `claude -p` call) — the everyday
455
+ cap+pins curation is automatic and free."""
456
+ import json as _json
457
+ import re as _re
458
+
459
+ from coord.state import list_issue_context, replace_issue_context
460
+ from coord.test_orchestrator import _call_claude
461
+
462
+ entries = list_issue_context(repo, issue)
463
+ if len(entries) <= 3:
464
+ click.echo(f"{repo} #{issue}: {len(entries)} entries — nothing to curate.")
465
+ return
466
+ payload = _json.dumps(
467
+ [{"body": e["body"], "pinned": e["pinned"], "source": e.get("source")}
468
+ for e in entries],
469
+ indent=2,
470
+ )
471
+ system = (
472
+ "You compress a SHORT per-issue engineering context digest injected at "
473
+ "the top of an AI agent's briefing. Rules: merge duplicates; drop "
474
+ "resolved / obsolete / now-irrelevant notes; KEEP every cross-repo "
475
+ "dependency, hard constraint, and failed-approach lesson; never invent "
476
+ "facts. Preserve pinned=true for criticals (deps/constraints). Aim for "
477
+ "<= 8 entries, each one tight line. Output ONLY a JSON array of "
478
+ '{"body": str, "pinned": bool} — no prose, no code fences.'
479
+ )
480
+ try:
481
+ raw = _call_claude(system, payload, model=model)
482
+ except Exception as exc: # noqa: BLE001
483
+ click.echo(f"error: curate failed: {exc}", err=True)
484
+ sys.exit(1)
485
+ match = _re.search(r"\[.*\]", raw, _re.DOTALL)
486
+ try:
487
+ parsed = _json.loads(match.group(0)) if match else None
488
+ assert isinstance(parsed, list)
489
+ except Exception: # noqa: BLE001
490
+ click.echo(
491
+ "error: curate returned unparseable output; context left unchanged.",
492
+ err=True,
493
+ )
494
+ sys.exit(1)
495
+ cleaned = [
496
+ {"body": str(e.get("body", "")).strip(),
497
+ "pinned": bool(e.get("pinned")), "source": "curated"}
498
+ for e in parsed
499
+ if str(e.get("body", "")).strip()
500
+ ]
501
+ if not cleaned:
502
+ click.echo(
503
+ "error: curate produced no entries; context left unchanged.", err=True
504
+ )
505
+ sys.exit(1)
506
+ replace_issue_context(repo, issue, cleaned)
507
+ click.echo(f"curated {repo} #{issue}: {len(entries)} → {len(cleaned)} entries")
508
+
509
+
510
+ @click.command(
511
+ help=(
512
+ "Send an issue to the Pipeline as DISPATCHABLE by tagging it with "
513
+ "both the `coord` and `status:ready` labels on GitHub.\n\n"
514
+ "A dispatchable Pipeline:New card needs BOTH labels. Coordinator "
515
+ "issues are often *created* with `coord` already, so adding only "
516
+ "`coord` was a no-op that left them stuck without `status:ready` "
517
+ "(#486 Leg 4 bug). This now ensures both — idempotent: in the normal "
518
+ "Refining → Refined (`coord ready`) → Send flow the issue already has "
519
+ "`status:ready`, so only `coord` is added. Any pre-Pipeline "
520
+ "`status:refining` / `status:backlog` label is cleared, mirroring "
521
+ "`coord ready`.\n\n"
522
+ "REPO is the local repo name from coordinator.yml; ISSUE is the "
523
+ "GH issue number."
524
+ )
525
+ )
526
+
527
+
528
+ @click.argument("repo")
529
+ @click.argument("issue", type=int)
530
+ @_CONFIG_OPTION
531
+ def track(repo: str, issue: int, config_path: Path) -> None:
532
+ """#261/#486: TUI right-click 'Send to Pipeline' fires this command to
533
+ make the issue a dispatchable Pipeline:New card (`coord` + `status:ready`)."""
534
+ cfg = _load_config(config_path)
535
+ repo_entry = cfg.repo(repo)
536
+ slug = repo_entry.github if repo_entry else repo
537
+ _apply_label_change(
538
+ repo, issue, config_path,
539
+ add={"coord", "status:ready"},
540
+ remove_if_present={"status:refining", "status:backlog"},
541
+ success_message=(
542
+ f"#{issue} ({slug}) sent to Pipeline (coord + status:ready)"
543
+ ),
544
+ no_op_message=(
545
+ f"#{issue} ({slug}) already dispatchable "
546
+ "(coord + status:ready present)"
547
+ ),
548
+ )
549
+
550
+
551
+ @click.command(
552
+ help=(
553
+ "Remove an issue from the Pipeline, returning it to the Board's "
554
+ "Backlog. Strips the `coord` label (Pipeline membership is the "
555
+ "`coord` label, so this is the only way to evict a card) plus any "
556
+ "`status:*` label, so the issue lands in Backlog rather than "
557
+ "Refined/Refining.\n\n"
558
+ "Inverse of `coord track` (Send to Pipeline). The TUI right-click "
559
+ "'Drop to backlog' on a Pipeline row fires this.\n\n"
560
+ "REPO is the local repo name from coordinator.yml; ISSUE is the "
561
+ "GH issue number."
562
+ )
563
+ )
564
+
565
+
566
+ @click.argument("repo")
567
+ @click.argument("issue", type=int)
568
+ @_CONFIG_OPTION
569
+ def untrack(repo: str, issue: int, config_path: Path) -> None:
570
+ """#266: TUI right-click 'Drop to backlog' on a Pipeline row fires this to
571
+ evict the issue from the coord Pipeline (removes `coord` + any `status:*`)."""
572
+ cfg = _load_config(config_path)
573
+ repo_entry = cfg.repo(repo)
574
+ slug = repo_entry.github if repo_entry else repo
575
+ _apply_label_change(
576
+ repo, issue, config_path,
577
+ add=set(),
578
+ # #1500: also strip `status:queued` (see `backlog`'s identical
579
+ # note) — dropping a staged card out of the Pipeline entirely must
580
+ # not leave the marker behind to silently re-surface on a later
581
+ # `coord track`.
582
+ remove_if_present={
583
+ "coord", "status:ready", "status:refining", "status:backlog",
584
+ "status:queued",
585
+ },
586
+ success_message=f"#{issue} ({slug}) dropped to Backlog (removed from Pipeline)",
587
+ no_op_message=f"#{issue} ({slug}) not in the Pipeline (no coord label)",
588
+ )
589
+
590
+
591
+ @click.command(
592
+ help=(
593
+ "Drop an issue back to Backlog by removing its `status:*` label.\n\n"
594
+ "Symmetric with `coord refine` / `coord ready` — strips both "
595
+ "`status:refining` and `status:ready` if present, returning the "
596
+ "issue to the unscoped Backlog state.\n\n"
597
+ "REPO is the local repo name from coordinator.yml; ISSUE is the "
598
+ "GH issue number."
599
+ )
600
+ )
601
+
602
+
603
+ @click.argument("repo")
604
+ @click.argument("issue", type=int)
605
+ @_CONFIG_OPTION
606
+ def backlog(repo: str, issue: int, config_path: Path) -> None:
607
+ """#266: TUI right-click 'Drop to Backlog' fires this command to
608
+ walk a Refining/Refined row back to the unscoped Backlog state."""
609
+ cfg = _load_config(config_path)
610
+ repo_entry = cfg.repo(repo)
611
+ slug = repo_entry.github if repo_entry else repo
612
+ _apply_label_change(
613
+ repo, issue, config_path,
614
+ add=set(),
615
+ # #1500: also strip `status:queued` — an issue staged as "ready"
616
+ # (via `coord queue` / the TUI's "Mark ready") that gets dropped
617
+ # back to Backlog must not carry the marker back in with it; a
618
+ # stale `status:queued` would silently re-surface it as
619
+ # In-progress:ready the moment it's re-tracked into the Pipeline.
620
+ remove_if_present={"status:refining", "status:ready", "status:queued"},
621
+ success_message=f"#{issue} ({slug}) dropped to Backlog",
622
+ no_op_message=f"#{issue} ({slug}) already in Backlog (no status:* label)",
623
+ )
624
+
625
+
626
+ @click.command(
627
+ help=(
628
+ "Stage a Pipeline issue as \"next up\" by tagging it with "
629
+ "`status:queued` — moves a Pipeline:New card into "
630
+ "In-progress:`ready` with no dispatch (display + intent only).\n\n"
631
+ "Deliberately a separate label from `status:ready`: that one is "
632
+ "already applied automatically by `coord track` / the refinement "
633
+ "finalize step for every issue sent to the Pipeline, so it carries "
634
+ "no \"an operator specifically staged this\" signal.\n\n"
635
+ "REPO is the local repo name from coordinator.yml; ISSUE is the "
636
+ "GH issue number."
637
+ )
638
+ )
639
+ @click.argument("repo")
640
+ @click.argument("issue", type=int)
641
+ @_CONFIG_OPTION
642
+ def queue(repo: str, issue: int, config_path: Path) -> None:
643
+ """#1500: TUI right-click 'Mark ready' fires this command to stage a
644
+ Pipeline:New issue (or an epic + its non-Done children, one call per
645
+ issue) into In-progress:`ready`."""
646
+ cfg = _load_config(config_path)
647
+ repo_entry = cfg.repo(repo)
648
+ slug = repo_entry.github if repo_entry else repo
649
+ _apply_label_change(
650
+ repo, issue, config_path,
651
+ add={"status:queued"},
652
+ remove_if_present=set(),
653
+ success_message=f"#{issue} ({slug}) marked ready (status:queued)",
654
+ no_op_message=f"#{issue} ({slug}) already marked ready (status:queued present)",
655
+ )
656
+
657
+
658
+ @click.command(
659
+ help=(
660
+ "Reverse of `coord queue` — removes `status:queued`, returning an "
661
+ "In-progress:`ready` card to Pipeline:New.\n\n"
662
+ "REPO is the local repo name from coordinator.yml; ISSUE is the "
663
+ "GH issue number."
664
+ )
665
+ )
666
+ @click.argument("repo")
667
+ @click.argument("issue", type=int)
668
+ @_CONFIG_OPTION
669
+ def unqueue(repo: str, issue: int, config_path: Path) -> None:
670
+ """#1500: TUI right-click 'Unmark ready' fires this command to strip
671
+ `status:queued`, returning the issue to Pipeline:New."""
672
+ cfg = _load_config(config_path)
673
+ repo_entry = cfg.repo(repo)
674
+ slug = repo_entry.github if repo_entry else repo
675
+ _apply_label_change(
676
+ repo, issue, config_path,
677
+ add=set(),
678
+ remove_if_present={"status:queued"},
679
+ success_message=f"#{issue} ({slug}) unmarked ready (status:queued removed)",
680
+ no_op_message=f"#{issue} ({slug}) not marked ready (no status:queued label)",
681
+ )