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/plans.py ADDED
@@ -0,0 +1,543 @@
1
+ """Pure aggregation for ``coord plans`` — the data backbone for the TUI
2
+ "Plans" panel (#975).
3
+
4
+ Every public function here is **pure / board-driven**: all inputs are plain
5
+ Python values (milestone dicts, issue bodies, a :class:`~coord.models.Board`,
6
+ sets of open/terminal issue numbers). No GitHub calls, no subprocess, no I/O.
7
+ That keeps the stats cheap to unit-test with seeded fixtures.
8
+
9
+ The CLI command (``coord/commands/plans.py``) fetches the raw inputs from
10
+ GitHub and calls :func:`aggregate_plan` for each milestone in each configured
11
+ repo.
12
+
13
+ Design decisions (#974):
14
+ - **tracking issue = "epic"-labelled issue under the milestone**. This is the
15
+ same convention established in :mod:`coord.milestone_order`
16
+ (``TRACKING_ISSUE_LABEL = "epic"``). An open milestone with no such issue is
17
+ reported with ``has_work_order = False`` and a ``"no_work_order"``
18
+ ``needs_you`` signal.
19
+ - **Tracking-epic lookup considers closed epics too.** A milestone can stay
20
+ open on GitHub after its tracking epic has been closed (e.g. all
21
+ work-order nodes finished and someone tidied up the epic before
22
+ remembering to close the milestone). Reporting that as ``"no_work_order"``
23
+ would be backwards — it would read as "nobody wrote a work order yet" when
24
+ the real state is "this plan is done." So :func:`aggregate_repo_plans`
25
+ accepts an optional ``closed_tracking_issues`` list (closed, ``"epic"``-
26
+ labelled issues, e.g. from :func:`coord.github_ops.get_closed_epics`) and
27
+ merges it with ``open_issues`` before searching for the tracking issue —
28
+ mirroring the open+closed scan :func:`coord.serve_app` already does for the
29
+ same "find the epic" operation (the #795 Phase 3b per-milestone work-order
30
+ projection).
31
+ - **Work order scope only**. ``done``/``total`` counts count work-order
32
+ nodes, not all issues under the milestone (the work order is the declared
33
+ scope of automated dispatch).
34
+ - **No remote branch check during aggregation**. The ``branch_lookup``
35
+ parameter to :func:`~coord.milestone_order.ready_frontier` defaults to
36
+ ``lambda *_: []`` here — a background aggregate over many milestones should
37
+ not spawn N ``gh`` calls for branch checks. ``in_flight`` is derived purely
38
+ from the board's active assignments.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ from dataclasses import dataclass, field
45
+
46
+ from coord.issue_store import diff_audit_goals
47
+ from coord.milestone_order import (
48
+ TRACKING_ISSUE_LABEL,
49
+ WorkOrder,
50
+ parse_work_order,
51
+ ready_frontier,
52
+ )
53
+ from coord.models import Board
54
+
55
+
56
+ __all__ = [
57
+ "TRACKING_ISSUE_LABEL",
58
+ "PlanEntry",
59
+ "find_tracking_issue",
60
+ "aggregate_plan",
61
+ "aggregate_repo_plans",
62
+ ]
63
+
64
+
65
+ # ── Data model ───────────────────────────────────────────────────────────────
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class PlanEntry:
70
+ """Aggregated stats for one GitHub milestone in one repo.
71
+
72
+ All counts are scoped to the ``## Work order`` block of the milestone's
73
+ tracking epic. When there is no work order the counts are all 0 and
74
+ ``needs_you`` contains ``"no_work_order"``.
75
+
76
+ Attributes:
77
+ repo: coord-local repo name (``coordinator.yml`` key).
78
+ title: Milestone title on GitHub.
79
+ milestone_number: GitHub milestone number.
80
+ tracking_issue: GitHub issue number of the epic/tracking issue, or
81
+ ``None`` when none exists under this milestone.
82
+ May be a closed issue when the caller supplies
83
+ ``closed_tracking_issues`` to
84
+ :func:`aggregate_repo_plans`.
85
+ has_work_order: ``True`` iff the tracking body carries a parsed
86
+ ``## Work order`` block with ≥1 node.
87
+ ready_frontier: Number of work-order nodes ready to dispatch now
88
+ (dependencies met, unclaimed, not terminal).
89
+ blocked: Number of nodes blocked by unmet dependencies or a
90
+ conflict (i.e. *not* claimed by an active assignment).
91
+ in_flight: Number of nodes that are claimed by an active board
92
+ assignment or a remote ``issue-N-*`` branch.
93
+ done: Number of terminal (closed) work-order nodes.
94
+ total: Total number of nodes in the work order.
95
+ needs_you: Ordered list of attention signals. Possible values:
96
+
97
+ ``"no_work_order"``
98
+ Open milestone with no parseable work order.
99
+ ``"ready_waiting"``
100
+ ≥1 ready-frontier entry exists to dispatch.
101
+ ``"stalled"``
102
+ Open, has a work order, but nothing is ready *or*
103
+ in-flight and the milestone is not done.
104
+ ``"chat_pending"``
105
+ A ``type="milestone-chat"`` assignment is
106
+ currently active (running/pending) against the
107
+ tracking issue (#976) — an operator opened
108
+ ``coord milestone chat`` and hasn't finalized
109
+ it yet. Orthogonal to the other three signals:
110
+ it can appear alongside any of them (or alone
111
+ on an otherwise "done" milestone).
112
+
113
+ outcome_run_number, outcome_met, outcome_partial, outcome_gap,
114
+ outcome_bottom_line, outcome_diff_summary:
115
+ #886 Phase 2 — the latest Milestone Outcome Audit
116
+ (``--audit-of``) verdict for this milestone's epic,
117
+ independent of the issue-closed counts above. All
118
+ ``None`` when no audit has ever run against this
119
+ epic. ``outcome_diff_summary`` is a pre-rendered
120
+ one-line delta vs the previous run (e.g. "closed:
121
+ tests.rs split; still open: #550"), ``None`` on the
122
+ first run (nothing to diff against yet).
123
+ """
124
+
125
+ repo: str
126
+ title: str
127
+ milestone_number: int
128
+ tracking_issue: int | None
129
+ has_work_order: bool
130
+ ready_frontier: int
131
+ blocked: int
132
+ in_flight: int
133
+ done: int
134
+ total: int
135
+ needs_you: list[str] = field(default_factory=list)
136
+ outcome_run_number: int | None = None
137
+ outcome_met: int | None = None
138
+ outcome_partial: int | None = None
139
+ outcome_gap: int | None = None
140
+ outcome_bottom_line: str | None = None
141
+ outcome_diff_summary: str | None = None
142
+
143
+ def to_dict(self) -> dict:
144
+ """Serialise to a plain dict suitable for ``json.dumps``."""
145
+ return {
146
+ "repo": self.repo,
147
+ "title": self.title,
148
+ "milestone_number": self.milestone_number,
149
+ "tracking_issue": self.tracking_issue,
150
+ "has_work_order": self.has_work_order,
151
+ "ready_frontier": self.ready_frontier,
152
+ "blocked": self.blocked,
153
+ "in_flight": self.in_flight,
154
+ "done": self.done,
155
+ "total": self.total,
156
+ "needs_you": list(self.needs_you),
157
+ "outcome_run_number": self.outcome_run_number,
158
+ "outcome_met": self.outcome_met,
159
+ "outcome_partial": self.outcome_partial,
160
+ "outcome_gap": self.outcome_gap,
161
+ "outcome_bottom_line": self.outcome_bottom_line,
162
+ "outcome_diff_summary": self.outcome_diff_summary,
163
+ }
164
+
165
+
166
+ # ── Pure helpers ─────────────────────────────────────────────────────────────
167
+
168
+
169
+ def find_tracking_issue(
170
+ milestone_number: int,
171
+ issues: list[dict],
172
+ ) -> dict | None:
173
+ """Return the first issue under *milestone_number* labelled ``"epic"``.
174
+
175
+ ``issues`` is a raw issue-dict list — each item has at least ``"number"``,
176
+ ``"labels"`` (list of ``{"name": ...}`` dicts), and ``"milestone"``
177
+ (``None`` or ``{"number": ...}``), matching the shape returned by
178
+ :func:`coord.github_ops.get_open_issues` / :func:`~coord.github_ops.get_closed_epics`.
179
+ This function does not care about issue state — callers that want a
180
+ closed epic to still count as the tracking issue (see
181
+ :func:`aggregate_repo_plans`) pass a list that already includes closed
182
+ epics alongside the open issues.
183
+
184
+ Returns ``None`` when no epic is found for this milestone. The caller
185
+ decides how to report a missing tracking issue.
186
+ """
187
+ for issue in issues:
188
+ ms = issue.get("milestone") or {}
189
+ if ms.get("number") != milestone_number:
190
+ continue
191
+ labels = [lbl.get("name", "") for lbl in (issue.get("labels") or [])]
192
+ if TRACKING_ISSUE_LABEL in labels:
193
+ return issue
194
+ return None
195
+
196
+
197
+ def _has_pending_chat(
198
+ board: Board,
199
+ repo_name: str,
200
+ tracking_issue_number: int | None,
201
+ ) -> bool:
202
+ """True iff an active ``type="milestone-chat"`` assignment targets
203
+ *tracking_issue_number* in *repo_name* (#976).
204
+
205
+ ``coord milestone chat <repo> <tracking_issue>``
206
+ (:func:`coord.milestone_chat.dispatch_milestone_chat`) records the
207
+ session as a normal :class:`~coord.models.Assignment` with
208
+ ``issue_number=tracking_issue_number`` — the same board state every
209
+ other claim check reads, so this needs no new plumbing. Mirrors the
210
+ ``a.status == "failed"`` skip in :func:`coord.claim.find_work_claim`: a
211
+ failed chat dispatch never actually opened, so it isn't "pending."
212
+ ``None`` tracking issue (no epic yet) can't have a chat dispatched
213
+ against it — always ``False``.
214
+ """
215
+ if tracking_issue_number is None:
216
+ return False
217
+ return any(
218
+ a.type == "milestone-chat"
219
+ and a.repo_name == repo_name
220
+ and a.issue_number == tracking_issue_number
221
+ and a.status != "failed"
222
+ for a in board.active
223
+ )
224
+
225
+
226
+ def _latest_audit_outcome(
227
+ board: Board, repo_name: str, tracking_issue_number: int | None,
228
+ ) -> dict | None:
229
+ """The latest ``--audit-of`` verdict for this milestone's epic (#886
230
+ Phase 2), or ``None`` when no audit has ever run against it.
231
+
232
+ Scans ``board.completed`` for ``type="audit"`` rows keyed by
233
+ ``(repo_name, tracking_issue_number)`` — the epic's own issue number
234
+ doubles as the audit assignment's ``issue_number`` (see #885's
235
+ ``_dispatch_audit_of``) — and picks the highest ``audit_run_number``.
236
+ When a second-highest run also exists, pre-renders a short delta string
237
+ (via :func:`coord.issue_store.diff_audit_goals`) so callers (the TUI)
238
+ don't need to re-derive a diff from two raw JSON blobs. Board-driven and
239
+ pure, like the rest of this module — no DB access here even though the
240
+ Board's assignment rows ultimately came from one.
241
+ """
242
+ if tracking_issue_number is None:
243
+ return None
244
+ runs = sorted(
245
+ (
246
+ a
247
+ for a in board.completed
248
+ if a.type == "audit"
249
+ and a.repo_name == repo_name
250
+ and a.issue_number == tracking_issue_number
251
+ and a.audit_run_number is not None
252
+ ),
253
+ key=lambda a: a.audit_run_number,
254
+ )
255
+ if not runs:
256
+ return None
257
+ latest = runs[-1]
258
+ try:
259
+ latest_goals = json.loads(latest.audit_goals_json or "[]")
260
+ except (TypeError, ValueError):
261
+ latest_goals = []
262
+ met = sum(1 for g in latest_goals if g.get("verdict") == "met")
263
+ gap = sum(1 for g in latest_goals if g.get("verdict") == "gap")
264
+ total = len(latest_goals)
265
+ partial = total - met - gap
266
+
267
+ diff_summary: str | None = None
268
+ if len(runs) >= 2:
269
+ prev = runs[-2]
270
+ try:
271
+ prev_goals = json.loads(prev.audit_goals_json or "[]")
272
+ except (TypeError, ValueError):
273
+ prev_goals = []
274
+ diff = diff_audit_goals(prev_goals, latest_goals)
275
+ parts = []
276
+ if diff["closed"]:
277
+ parts.append(f"closed: {', '.join(diff['closed'])}")
278
+ if diff["regressed"]:
279
+ parts.append(f"REGRESSED: {', '.join(diff['regressed'])}")
280
+ if diff["still_open"]:
281
+ parts.append(f"still open: {', '.join(diff['still_open'])}")
282
+ if parts:
283
+ diff_summary = (
284
+ f"v{prev.audit_run_number}→v{latest.audit_run_number}: "
285
+ + "; ".join(parts)
286
+ )
287
+
288
+ return {
289
+ "run_number": latest.audit_run_number,
290
+ "met": met,
291
+ "partial": partial,
292
+ "gap": gap,
293
+ "total": total,
294
+ "bottom_line": latest.audit_bottom_line or "",
295
+ "diff_summary": diff_summary,
296
+ }
297
+
298
+
299
+ def aggregate_plan(
300
+ *,
301
+ milestone_title: str,
302
+ milestone_number: int,
303
+ repo_name: str,
304
+ repo_github: str,
305
+ tracking_issue_number: int | None,
306
+ tracking_body: str | None,
307
+ board: Board,
308
+ open_issue_numbers: set[int],
309
+ ) -> PlanEntry:
310
+ """Compute a :class:`PlanEntry` for one milestone.
311
+
312
+ Parameters
313
+ ----------
314
+ milestone_title:
315
+ GitHub milestone title.
316
+ milestone_number:
317
+ GitHub milestone number.
318
+ repo_name:
319
+ coord-local repo name.
320
+ repo_github:
321
+ GitHub slug (``owner/repo``).
322
+ tracking_issue_number:
323
+ Issue number of the epic/tracking issue, or ``None``.
324
+ tracking_body:
325
+ Body text of the tracking issue, or ``None`` when there is no
326
+ tracking issue. An empty string is treated the same as ``None``
327
+ (no work order).
328
+ board:
329
+ Current board state, used to find active assignments for in-flight
330
+ detection.
331
+ open_issue_numbers:
332
+ Set of GitHub issue numbers that are currently *open* under this
333
+ milestone. Any work-order node **not** in this set is assumed
334
+ terminal (closed).
335
+
336
+ Returns
337
+ -------
338
+ PlanEntry
339
+ Aggregated stats. When ``tracking_body`` is ``None`` / empty, or the
340
+ body has no ``## Work order`` block, ``has_work_order`` is ``False``
341
+ and all counts are 0.
342
+ """
343
+ chat_pending = _has_pending_chat(board, repo_name, tracking_issue_number)
344
+ # #886 Phase 2: the audit outcome is independent of the work-order/issue
345
+ # counts below (the whole point — completion judged by goals met, not
346
+ # issues closed), so it's computed once and attached to EVERY return path,
347
+ # including the has_work_order=False ones.
348
+ outcome = _latest_audit_outcome(board, repo_name, tracking_issue_number)
349
+ outcome_kwargs = (
350
+ {
351
+ "outcome_run_number": outcome["run_number"],
352
+ "outcome_met": outcome["met"],
353
+ "outcome_partial": outcome["partial"],
354
+ "outcome_gap": outcome["gap"],
355
+ "outcome_bottom_line": outcome["bottom_line"],
356
+ "outcome_diff_summary": outcome["diff_summary"],
357
+ }
358
+ if outcome is not None
359
+ else {}
360
+ )
361
+
362
+ _no_work_order_signals = ["no_work_order"]
363
+ if chat_pending:
364
+ _no_work_order_signals.append("chat_pending")
365
+ _no_work_order = PlanEntry(
366
+ repo=repo_name,
367
+ title=milestone_title,
368
+ milestone_number=milestone_number,
369
+ tracking_issue=tracking_issue_number,
370
+ has_work_order=False,
371
+ ready_frontier=0,
372
+ blocked=0,
373
+ in_flight=0,
374
+ done=0,
375
+ total=0,
376
+ needs_you=_no_work_order_signals,
377
+ **outcome_kwargs,
378
+ )
379
+
380
+ if not tracking_body:
381
+ return _no_work_order
382
+
383
+ try:
384
+ work_order: WorkOrder = parse_work_order(tracking_body)
385
+ except Exception: # noqa: BLE001 — malformed work order; treat as absent
386
+ return _no_work_order
387
+
388
+ if not work_order.nodes:
389
+ return _no_work_order
390
+
391
+ # Terminal = declared in the work order but not currently open.
392
+ terminal_issues: set[int] = {
393
+ n.issue_number
394
+ for n in work_order.nodes
395
+ if n.issue_number not in open_issue_numbers
396
+ }
397
+
398
+ total = len(work_order.nodes)
399
+ done = len(terminal_issues)
400
+
401
+ frontier = ready_frontier(
402
+ work_order,
403
+ board,
404
+ repo_name=repo_name,
405
+ repo_github=repo_github,
406
+ terminal_issues=terminal_issues,
407
+ # No remote branch check here: a bulk aggregate must not spawn N gh
408
+ # calls for branch lookups. In-flight is derived from the board only.
409
+ branch_lookup=lambda *_: [],
410
+ )
411
+
412
+ ready_count = len(frontier.ready)
413
+ in_flight_count = sum(1 for b in frontier.blocked if b.claim is not None)
414
+ blocked_count = sum(1 for b in frontier.blocked if b.claim is None)
415
+
416
+ needs_you: list[str] = []
417
+ if ready_count > 0:
418
+ needs_you.append("ready_waiting")
419
+ elif done < total and in_flight_count == 0:
420
+ needs_you.append("stalled")
421
+ if chat_pending:
422
+ needs_you.append("chat_pending")
423
+
424
+ return PlanEntry(
425
+ repo=repo_name,
426
+ title=milestone_title,
427
+ milestone_number=milestone_number,
428
+ tracking_issue=tracking_issue_number,
429
+ has_work_order=True,
430
+ ready_frontier=ready_count,
431
+ blocked=blocked_count,
432
+ in_flight=in_flight_count,
433
+ done=done,
434
+ total=total,
435
+ needs_you=needs_you,
436
+ **outcome_kwargs,
437
+ )
438
+
439
+
440
+ def aggregate_repo_plans(
441
+ *,
442
+ repo_name: str,
443
+ repo_github: str,
444
+ milestones: list[dict],
445
+ open_issues: list[dict],
446
+ board: Board,
447
+ closed_tracking_issues: list[dict] | None = None,
448
+ issue_body_fetcher: "Callable[[int], str | None] | None" = None,
449
+ ) -> list[PlanEntry]:
450
+ """Aggregate plans for all milestones in one repo.
451
+
452
+ Parameters
453
+ ----------
454
+ repo_name:
455
+ coord-local repo name.
456
+ repo_github:
457
+ GitHub slug (``owner/repo``).
458
+ milestones:
459
+ List of milestone dicts (``{number, title, ...}``), as returned by
460
+ :func:`coord.github_ops.get_repo_milestones`.
461
+ open_issues:
462
+ List of all open issue dicts for the repo, as returned by
463
+ :func:`coord.github_ops.get_open_issues`.
464
+ board:
465
+ Current board state.
466
+ closed_tracking_issues:
467
+ Optional list of *closed* ``"epic"``-labelled issue dicts (e.g. from
468
+ :func:`coord.github_ops.get_closed_epics`), merged with
469
+ ``open_issues`` when searching for each milestone's tracking issue.
470
+ Without this, a milestone whose tracking epic was closed while the
471
+ milestone stayed open would be reported as ``"no_work_order"``
472
+ instead of reflecting its (likely done) work order. ``None``/``[]``
473
+ preserves the open-only lookup.
474
+ issue_body_fetcher:
475
+ Optional callable ``(issue_number: int) -> str | None`` that fetches
476
+ the body of a *single* issue by number. Required only when the
477
+ tracking epic's body is not already in ``open_issues`` /
478
+ ``closed_tracking_issues`` (it always should be, but the hook lets
479
+ callers supply a pre-fetched body map for testing). When ``None``,
480
+ body is read from the matching entry in those snapshots only.
481
+
482
+ Returns
483
+ -------
484
+ list[PlanEntry]
485
+ One entry per milestone, in the same order as *milestones*.
486
+ """
487
+ from typing import Callable # noqa: PLC0415 — keep import cheap
488
+
489
+ closed_tracking_issues = closed_tracking_issues or []
490
+
491
+ # Candidates for tracking-issue lookup: open issues + any closed epics
492
+ # supplied by the caller. Deliberately kept separate from open-issue-only
493
+ # terminal detection below (a closed epic isn't a work-order node).
494
+ tracking_candidates: list[dict] = open_issues + closed_tracking_issues
495
+
496
+ # Build a quick index: issue_number → body from the open + closed-epic
497
+ # snapshots.
498
+ body_index: dict[int, str] = {
499
+ i["number"]: (i.get("body") or "")
500
+ for i in tracking_candidates
501
+ }
502
+
503
+ # Build open-issue-numbers per milestone.
504
+ open_by_milestone: dict[int, set[int]] = {}
505
+ for issue in open_issues:
506
+ ms = issue.get("milestone") or {}
507
+ ms_num = ms.get("number")
508
+ if ms_num is not None:
509
+ open_by_milestone.setdefault(ms_num, set()).add(issue["number"])
510
+
511
+ entries: list[PlanEntry] = []
512
+ for ms in milestones:
513
+ ms_num: int = ms["number"]
514
+ ms_title: str = ms.get("title", f"Milestone #{ms_num}")
515
+
516
+ tracking = find_tracking_issue(ms_num, tracking_candidates)
517
+ tracking_number: int | None = tracking["number"] if tracking is not None else None
518
+
519
+ if tracking_number is not None:
520
+ body: str | None = body_index.get(tracking_number)
521
+ # Call the fetcher when the body is absent or empty — an empty
522
+ # string from the snapshot means the issue was returned without
523
+ # body text, so we fall back to a direct fetch if available.
524
+ if not body and issue_body_fetcher is not None:
525
+ body = issue_body_fetcher(tracking_number)
526
+ else:
527
+ body = None
528
+
529
+ open_nums = open_by_milestone.get(ms_num, set())
530
+
531
+ entry = aggregate_plan(
532
+ milestone_title=ms_title,
533
+ milestone_number=ms_num,
534
+ repo_name=repo_name,
535
+ repo_github=repo_github,
536
+ tracking_issue_number=tracking_number,
537
+ tracking_body=body,
538
+ board=board,
539
+ open_issue_numbers=open_nums,
540
+ )
541
+ entries.append(entry)
542
+
543
+ return entries
@@ -0,0 +1,43 @@
1
+ """Cross-platform resolution of the coordinator's on-disk state root (#1156).
2
+
3
+ Every existing POSIX deployment (Linux daemon boxes, the fleet's agent
4
+ machines) keeps ``~/.coord`` exactly as it always has -- that value is baked
5
+ into every runbook, systemd unit, and support doc, and disturbing it would be
6
+ a needless back-compat break for the fleet that already runs on it.
7
+
8
+ Windows and macOS instead resolve through :mod:`platformdirs` to an OS-native
9
+ application-data directory rather than masquerading as a Unix dotfile under
10
+ ``%USERPROFILE%``/``$HOME``.
11
+
12
+ This is the *one* seam the state-root constants across the package
13
+ (``coord.db.COORD_DIR``, ``coord.state.COORD_DIR``, ``coord.config.USER_CONFIG_PATH``,
14
+ ``coord.agent.DEFAULT_STATE_DIR``) derive from, so the POSIX/Windows/macOS
15
+ decision lives in exactly one place instead of drifting across four
16
+ independent ``Path.home() / ".coord"`` literals.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ #: Platforms that get an OS-native directory via `platformdirs` instead of
25
+ #: the legacy `~/.coord` dotfile. Deliberately keyed on `sys.platform`, not
26
+ #: `os.name` -- macOS reports `os.name == "posix"` (and every POSIX import
27
+ #: this issue guards -- fcntl/termios/tty -- is present there too) but should
28
+ #: still land in `~/Library/Application Support/coord`, not `~/.coord`.
29
+ _NATIVE_DIR_PLATFORMS = ("win32", "darwin")
30
+
31
+
32
+ def default_coord_dir() -> Path:
33
+ """Resolve the coordinator's state root for the current platform.
34
+
35
+ POSIX (Linux, and any other non-Windows/non-macOS *nix) keeps ``~/.coord``
36
+ for back-compat with every existing deployment. Windows and macOS resolve
37
+ through ``platformdirs`` to their OS-native application-data directory.
38
+ """
39
+ if sys.platform in _NATIVE_DIR_PLATFORMS:
40
+ import platformdirs # noqa: PLC0415 -- keep this leaf module import-light on POSIX
41
+
42
+ return Path(platformdirs.user_data_dir("coord", appauthor=False))
43
+ return Path.home() / ".coord"
coord/pr_body_lint.py ADDED
@@ -0,0 +1,67 @@
1
+ """Pre-merge PR-body lint (#1196, hole 2).
2
+
3
+ GitHub's own closing-keyword magic (``Closes #N`` / ``Fixes #N`` / ``Resolves
4
+ #N`` in a merged PR's body) auto-closes the referenced issue on merge — and
5
+ it never calls :func:`coord.github_ops.close_issue`, so that function's
6
+ open-children guard (#1196) cannot stop it. A worker-authored PR is free to
7
+ ask an LLM to write ``Closes #<epic>`` in its body; nothing before this
8
+ scanned, linted, or rejected it (the gap #1196 calls out explicitly:
9
+ ``merge_queue.py`` only controls the *keyword the coordinator itself
10
+ writes* via ``_briefing_body``/``CLOSES_ISSUE_TYPES``, not what a worker's
11
+ own ``gh pr create`` puts in the body).
12
+
13
+ This module is pure (no ``gh``/network calls) so the regex/rewrite logic is
14
+ unit-testable in isolation; :func:`coord.merge_queue.process` does the
15
+ network side (fetch body, check each referenced issue for open children via
16
+ ``github_ops.has_open_children``, call this, write back via
17
+ ``github_ops.edit_pr_body`` when anything changed).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+
24
+ __all__ = ["find_closing_references", "downgrade_closing_keywords"]
25
+
26
+ # The GitHub closing-keyword vocabulary (case-insensitive): close/closes/
27
+ # closed, fix/fixes/fixed, resolve/resolves/resolved. Deliberately matches
28
+ # only the simple "<keyword> #<N>" shape actually used in this codebase's
29
+ # own PR bodies (`_briefing_body`, `_find_or_open_pr`) — GitHub also accepts
30
+ # comma-lists ("Closes #1, #2") and cross-repo refs, but every body this
31
+ # coordinator or its workers produce uses one keyword per issue reference.
32
+ _CLOSING_RE = re.compile(
33
+ r"\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b(\s+)#(\d+)",
34
+ re.IGNORECASE,
35
+ )
36
+
37
+
38
+ def find_closing_references(body: str) -> list[int]:
39
+ """Return every issue number referenced by a GitHub closing keyword in
40
+ *body*, in first-seen order (duplicates removed)."""
41
+ seen: list[int] = []
42
+ for m in _CLOSING_RE.finditer(body or ""):
43
+ n = int(m.group(3))
44
+ if n not in seen:
45
+ seen.append(n)
46
+ return seen
47
+
48
+
49
+ def downgrade_closing_keywords(body: str, numbers: set[int]) -> tuple[str, list[int]]:
50
+ """Rewrite ``<keyword> #N`` -> ``Refs #N`` for every ``N`` in *numbers*.
51
+
52
+ Returns ``(possibly-rewritten body, sorted list of numbers actually
53
+ downgraded)``. References to issue numbers not in *numbers* are left
54
+ exactly as written (including the original keyword's casing) — this
55
+ keeps the common case (a regular issue with no children) untouched.
56
+ """
57
+ downgraded: list[int] = []
58
+
59
+ def _sub(m: re.Match) -> str:
60
+ n = int(m.group(3))
61
+ if n not in numbers:
62
+ return m.group(0)
63
+ downgraded.append(n)
64
+ return f"Refs{m.group(2)}#{n}"
65
+
66
+ new_body = _CLOSING_RE.sub(_sub, body or "")
67
+ return new_body, sorted(set(downgraded))