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,840 @@
1
+ """Milestone work-order representation — Phase 0 of #767 (+ the render/replace
2
+ write-side helpers Phase 2, #770, uses to persist a chat-proposed order).
3
+
4
+ A milestone's **tracking issue** (the decision-log convention established by
5
+ #645) carries a ``## Work order`` annotated checklist describing which of
6
+ the milestone's issues may run concurrently and which have hard dependency
7
+ edges::
8
+
9
+ ## Work order
10
+ - [ ] #762 {group: A} # may run concurrently (cohort A)
11
+ - [ ] #763 {group: A}
12
+ - [ ] #765 {after: #762,#763} # hard dependency edge
13
+ - [ ] #766 {after: #765}
14
+
15
+ This module turns that block into a DAG (:func:`parse_work_order`) and
16
+ computes the **ready frontier** — the subset of nodes eligible to dispatch
17
+ right now given the current board state and which issues have already
18
+ reached a merged/terminal state (:func:`ready_frontier`). It also renders a
19
+ :class:`WorkOrder` back into checklist text and splices it into a tracking
20
+ issue's body (:func:`render_work_order` / :func:`replace_work_order_section`)
21
+ — the write-side counterpart the #770 milestone-chat session (and its
22
+ ``coord milestone write-order`` CLI command) uses to persist an
23
+ operator-confirmed order idempotently, never duplicating the section.
24
+
25
+ Deliberately **pure / board-driven** (per #768's acceptance criteria): every
26
+ function here takes plain data (a body string, a :class:`~coord.models.Board`,
27
+ a set of terminal issue numbers, a set of milestone issue numbers) rather
28
+ than reaching out to GitHub itself. That keeps the DAG/frontier logic cheap
29
+ to unit-test with seeded fixtures and keeps the one "mechanical, ~zero
30
+ Claude per decision" property #767 calls for — no LLM, no network call, on
31
+ the hot path. Fetching those inputs from GitHub (the tracking issue body,
32
+ milestone membership, issue open/closed state) is the job of the
33
+ ``coord milestone order`` CLI command (``coord/commands/milestone.py``),
34
+ which is thin glue over this module.
35
+
36
+ Design note for later phases (#769+): milestone **membership** is checked
37
+ against "is this issue number under the milestone" — not "is it currently
38
+ open". A dependency that has already merged and closed by the time you
39
+ re-run ``coord milestone order`` is expected and still a valid node; only
40
+ its *readiness* (via ``terminal_issues``) changes, not its validity in the
41
+ DAG. Only a genuinely wrong/foreign issue number raises
42
+ ``WorkOrderError``.
43
+
44
+ #1008 adds a second checklist convention living in the *same* tracking-issue
45
+ body: ``## Sub-issues`` — the epic's child-issue list, spliced by ``coord
46
+ milestone add-child`` (:func:`parse_sub_issues` / :func:`render_sub_issues`
47
+ / :func:`replace_sub_issues_section`). It reuses the identical `- [ ] #N
48
+ {group: ..., after: ...}` checklist grammar and the same
49
+ :class:`WorkOrderNode` / :class:`WorkOrder` shapes as the work order — only
50
+ the section heading differs — so the two conventions can coexist in one
51
+ tracking-issue body without either splice helper disturbing the other.
52
+
53
+ #1061: the `[ ]`/`[x]` checkbox is decorative — parsed into
54
+ :attr:`WorkOrderNode.checked`, preserved, and rendered, but never read for
55
+ readiness (:func:`ready_frontier` keys entirely off live ``terminal_issues``)
56
+ — which is exactly why it silently drifted stale on real epics. Rather than
57
+ sync it, the grammar is migrating to drop it: `- #N {group: ..., after:
58
+ ...}`, no checkbox. Both forms parse identically (the checkbox is simply
59
+ optional now) so old bodies keep working during the migration;
60
+ ``coord milestone sync`` (:mod:`coord.commands.milestone`) is the write path
61
+ that rewrites an epic's `## Work order` to the checkbox-free form, backfills
62
+ the live GitHub sub-issues API for each referenced child
63
+ (:mod:`coord.parentage_github`), and retires the now-redundant `##
64
+ Sub-issues` section (:func:`remove_sub_issues_section`) — the API + `##
65
+ Work order` together already carry everything that section did.
66
+
67
+ #1412 adds a third, purely *derived* section: `## Progress`. Unlike `##
68
+ Work order`/`## Sub-issues` (both hand-authored plans a human or a milestone
69
+ chat session edits), `## Progress` is never edited directly — it's a
70
+ read-only projection of live status onto the tracking-issue body, refreshed
71
+ by ``coord milestone sync-progress`` (one-shot, operator-triggered) and the
72
+ daemon tick (:func:`coord.serve_app._milestone_progress_tick`, for every
73
+ milestone registered via ``coord milestone dispatch``). :func:`compute_progress`
74
+ derives one :class:`ProgressStatus` per work-order node from exactly the
75
+ inputs ``coord milestone order`` already prints — terminal state + a live
76
+ :func:`ready_frontier` — so there is deliberately no second notion of
77
+ readiness anywhere in this module; `## Progress` can only ever agree with
78
+ what ``coord milestone order``/``dispatch`` would compute right now.
79
+ :func:`render_progress` turns that into checklist-shaped text (a timestamp
80
+ line + one `- [status] #N` line per node) and :func:`parse_progress` is its
81
+ inverse, used only to detect a no-op (re-rendering identical status text
82
+ should not touch the tracking issue, even though the timestamp itself always
83
+ changes) — never for readiness. :func:`replace_progress_section` splices it
84
+ in under its own `## Progress` heading, using the exact same
85
+ splice-not-duplicate mechanics as :func:`replace_work_order_section` /
86
+ :func:`replace_sub_issues_section`, so all three sections coexist in one
87
+ tracking-issue body without stepping on each other.
88
+ """
89
+
90
+ from __future__ import annotations
91
+
92
+ import re
93
+ from dataclasses import dataclass, field
94
+ from typing import Callable, Iterable
95
+
96
+ from coord.claim import BranchLookup, Claim, find_work_claim
97
+ from coord.models import Board
98
+
99
+
100
+ __all__ = [
101
+ "TRACKING_ISSUE_LABEL",
102
+ "WorkOrderError",
103
+ "WorkOrderNode",
104
+ "WorkOrder",
105
+ "parse_work_order",
106
+ "render_work_order",
107
+ "replace_work_order_section",
108
+ "parse_sub_issues",
109
+ "render_sub_issues",
110
+ "replace_sub_issues_section",
111
+ "remove_sub_issues_section",
112
+ "milestone_work_order_membership",
113
+ "validate_milestone_membership",
114
+ "FrontierEntry",
115
+ "BlockedNode",
116
+ "Frontier",
117
+ "ready_frontier",
118
+ "ProgressStatus",
119
+ "compute_progress",
120
+ "render_progress",
121
+ "parse_progress",
122
+ "replace_progress_section",
123
+ ]
124
+
125
+ # #645 task 5: the tracking-issue convention. A milestone's tracking issue —
126
+ # the issue whose body carries the `## Work order` block above and whose
127
+ # comment stream is the milestone's decision log — is identified by this
128
+ # label, assigned to the milestone itself. This repo has used ``"epic"`` for
129
+ # that role in practice since before #645 codified it (see #767/#884); a
130
+ # future `--milestone-chat` session (#645 task 2) reads/writes the tracking
131
+ # issue found via ``label:epic milestone:<title>``, and can create one
132
+ # (`coord issue create --label epic --milestone ...`) when a milestone
133
+ # doesn't have one yet.
134
+ TRACKING_ISSUE_LABEL = "epic"
135
+
136
+
137
+ class WorkOrderError(ValueError):
138
+ """A `## Work order` block failed validation.
139
+
140
+ The message always names the offending issue and the violated
141
+ constraint (duplicate node, unknown annotation key, an ``after`` edge to
142
+ an undeclared issue, a dependency cycle, or milestone-membership
143
+ mismatch) so a human can fix the tracking-issue body without having to
144
+ re-derive what went wrong.
145
+ """
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class WorkOrderNode:
150
+ """One `- [ ] #N {...}` line from the work-order block."""
151
+
152
+ issue_number: int
153
+ group: str | None = None
154
+ after: tuple[int, ...] = field(default_factory=tuple)
155
+ checked: bool = False # `- [x]` vs `- [ ]` in the source block
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class WorkOrder:
160
+ """The parsed `## Work order` block: a DAG of :class:`WorkOrderNode`."""
161
+
162
+ nodes: tuple[WorkOrderNode, ...] = field(default_factory=tuple)
163
+
164
+ def node(self, issue_number: int) -> WorkOrderNode | None:
165
+ return next((n for n in self.nodes if n.issue_number == issue_number), None)
166
+
167
+ @property
168
+ def issue_numbers(self) -> tuple[int, ...]:
169
+ return tuple(n.issue_number for n in self.nodes)
170
+
171
+
172
+ # ── Parsing ──────────────────────────────────────────────────────────────────
173
+
174
+ _HEADING_RE = re.compile(r"^#{1,6}\s*Work order\s*$", re.IGNORECASE)
175
+ # #1008: the epic's child-issue checklist — same grammar, different heading.
176
+ _SUB_ISSUES_HEADING_RE = re.compile(r"^#{1,6}\s*Sub-issues\s*$", re.IGNORECASE)
177
+ # #1061: the `[ ]`/`[x]` checkbox is now optional — `checked` was parsed,
178
+ # preserved, and rendered but never read for readiness (`ready_frontier`
179
+ # keys entirely off live `terminal_issues`), so it's decorative and the
180
+ # grammar is migrating to drop it (`- #N {...}` instead of `- [ ] #N {...}`).
181
+ # Both forms parse identically during the migration; `coord milestone sync`
182
+ # is what rewrites existing bodies to the checkbox-free form.
183
+ _ITEM_RE = re.compile(r"^-\s*(?:\[([ xX])\]\s*)?#(\d+)\s*(\{([^}]*)\})?")
184
+ # Splits `key: value` pairs on commas that precede the *next* key, so an
185
+ # `after: #762,#763` value (itself comma-separated) isn't cut mid-list.
186
+ _PAIR_RE = re.compile(r"(\w+)\s*:\s*(.*?)(?=,\s*\w+\s*:|$)")
187
+ _AFTER_ITEM_RE = re.compile(r"#?(\d+)")
188
+
189
+
190
+ def parse_work_order(body: str) -> WorkOrder:
191
+ """Parse the `## Work order` block out of a tracking-issue body.
192
+
193
+ Returns an empty :class:`WorkOrder` (no nodes) when the body has no
194
+ `## Work order` heading — callers decide whether an empty work order is
195
+ an error in their context.
196
+
197
+ Raises :class:`WorkOrderError` for:
198
+ - a checklist-shaped line that doesn't match the `#N` convention
199
+ - the same issue number declared more than once
200
+ - an unknown annotation key (only ``group`` and ``after`` are defined)
201
+ - a malformed ``after`` entry (not `#N` / `N`)
202
+ - an ``after`` edge to an issue not itself declared in this block
203
+ - a dependency cycle
204
+ """
205
+ return _parse_checklist_section(body, _HEADING_RE, "work order")
206
+
207
+
208
+ def parse_sub_issues(body: str) -> WorkOrder:
209
+ """Parse the `## Sub-issues` block out of an epic tracking-issue body (#1008).
210
+
211
+ Mirrors :func:`parse_work_order` exactly — same checklist grammar
212
+ (`- [ ] #N {group: ..., after: ...}`), same validation (duplicates,
213
+ unknown annotation keys, malformed/undeclared ``after`` targets, cycles)
214
+ — only the section heading (`## Sub-issues` vs `## Work order`) and the
215
+ error-message label differ. Returns an empty :class:`WorkOrder` when the
216
+ body has no `## Sub-issues` heading. Reuses :class:`WorkOrder` /
217
+ :class:`WorkOrderNode` rather than introducing parallel types since the
218
+ shape is identical; ``coord milestone add-child`` is the write-side
219
+ counterpart (mirrors ``coord milestone write-order``'s relationship to
220
+ :func:`parse_work_order`).
221
+ """
222
+ return _parse_checklist_section(body, _SUB_ISSUES_HEADING_RE, "sub-issues")
223
+
224
+
225
+ def milestone_work_order_membership(issues: Iterable[dict]) -> list[dict]:
226
+ """Minimal ``milestone_work_orders`` projection: which tracking issue (if
227
+ any) each issue's ``## Work order`` block claims as a member — nothing
228
+ about readiness.
229
+
230
+ #2040: ``coord.serve_app``'s ``/board`` handler computes a RICHER
231
+ ``milestone_work_orders`` (ready/blocked/next-up per node, via
232
+ :func:`ready_frontier` over a live :class:`~coord.models.Board` +
233
+ ``Config``) for the TUI's Pipeline cards — that version needs I/O this
234
+ function deliberately avoids (a board snapshot, repo config). The ONE
235
+ consumer of ``milestone_work_orders`` outside that HTTP wire shape is
236
+ :func:`coord.drive_state.project`, and it only ever reads
237
+ ``tracking_issue`` plus membership (``nodes[].issue_number``) to resolve
238
+ ``IssueState.milestone_tracking_issue`` — so this gives
239
+ :class:`~coord.drive_state.BoardFetcher`'s daemon-host path (which has no
240
+ HTTP round trip to lean on) the same answer with no extra I/O beyond the
241
+ ``issues`` rows it already has.
242
+
243
+ *issues* is a ``/board``-shaped ``issues`` list (each dict carrying at
244
+ least ``repo_name``, ``number``, ``labels``, ``body``; ``milestone_title``
245
+ is optional). Same fail-open posture as the HTTP handler's block: a
246
+ tracking issue (``TRACKING_ISSUE_LABEL``) with no body, an unparseable
247
+ ``## Work order`` block, or an empty one is skipped rather than raising —
248
+ one bad epic must never blank the whole projection.
249
+ """
250
+ out: list[dict] = []
251
+ for ti in issues:
252
+ labels = ti.get("labels") or []
253
+ if TRACKING_ISSUE_LABEL not in labels:
254
+ continue
255
+ repo_name = ti.get("repo_name") or ""
256
+ if not repo_name:
257
+ continue
258
+ try:
259
+ wo = parse_work_order(ti.get("body") or "")
260
+ except Exception: # noqa: BLE001 — bad work order: skip this tracking issue
261
+ continue
262
+ if not wo.nodes:
263
+ continue
264
+ out.append({
265
+ "repo_name": repo_name,
266
+ "tracking_issue": ti.get("number"),
267
+ "milestone_title": ti.get("milestone_title") or "",
268
+ "nodes": [{"issue_number": n.issue_number} for n in wo.nodes],
269
+ })
270
+ return out
271
+
272
+
273
+ def _parse_checklist_section(
274
+ body: str, heading_re: re.Pattern[str], label: str
275
+ ) -> WorkOrder:
276
+ """Shared implementation behind :func:`parse_work_order` /
277
+ :func:`parse_sub_issues`: find *heading_re*'s section in *body*, parse
278
+ its `- [ ] #N {...}` lines into nodes, then validate (no duplicates,
279
+ every ``after`` target declared in the same section, no cycle).
280
+ *label* (e.g. ``"work order"`` / ``"sub-issues"``) is folded into every
281
+ :class:`WorkOrderError` message so a failure is traceable to the right
282
+ section of the tracking-issue body.
283
+ """
284
+ lines = body.splitlines()
285
+ start = None
286
+ for i, line in enumerate(lines):
287
+ if heading_re.match(line.strip()):
288
+ start = i + 1
289
+ break
290
+ if start is None:
291
+ return WorkOrder(nodes=())
292
+
293
+ nodes: list[WorkOrderNode] = []
294
+ seen: set[int] = set()
295
+ for line in lines[start:]:
296
+ stripped = line.strip()
297
+ if not stripped:
298
+ continue
299
+ if stripped.startswith("#"):
300
+ # A markdown heading — the section has ended.
301
+ break
302
+ m = _ITEM_RE.match(stripped)
303
+ if not m:
304
+ if stripped.startswith("-"):
305
+ raise WorkOrderError(
306
+ f"{label}: unparseable line: {stripped!r} "
307
+ "(expected '- [ ] #N {annotations}')"
308
+ )
309
+ continue
310
+ checked = m.group(1) is not None and m.group(1).lower() == "x"
311
+ issue_number = int(m.group(2))
312
+ if issue_number in seen:
313
+ raise WorkOrderError(
314
+ f"{label}: #{issue_number} is declared more than once"
315
+ )
316
+ seen.add(issue_number)
317
+ group, after = _parse_annotation(issue_number, m.group(4) or "", label)
318
+ nodes.append(WorkOrderNode(issue_number, group, tuple(after), checked))
319
+
320
+ numbers = {n.issue_number for n in nodes}
321
+ for n in nodes:
322
+ for target in n.after:
323
+ if target not in numbers:
324
+ raise WorkOrderError(
325
+ f"{label}: #{n.issue_number} has after:#{target}, "
326
+ f"but #{target} is not declared in the {label} block"
327
+ )
328
+
329
+ _check_cycles(nodes, label)
330
+ return WorkOrder(nodes=tuple(nodes))
331
+
332
+
333
+ def _parse_annotation(
334
+ issue_number: int, raw: str, label: str = "work order"
335
+ ) -> tuple[str | None, list[int]]:
336
+ raw = raw.strip()
337
+ if not raw:
338
+ return None, []
339
+ group: str | None = None
340
+ after: list[int] = []
341
+ matched_any = False
342
+ for pair in _PAIR_RE.finditer(raw):
343
+ key = pair.group(1).strip().lower()
344
+ value = pair.group(2).strip().rstrip(",").strip()
345
+ matched_any = True
346
+ if key == "group":
347
+ group = value
348
+ elif key == "after":
349
+ after = _parse_after_list(issue_number, value, label)
350
+ else:
351
+ raise WorkOrderError(
352
+ f"{label}: #{issue_number} has unknown annotation key "
353
+ f"{key!r} (expected 'group' or 'after')"
354
+ )
355
+ if not matched_any:
356
+ raise WorkOrderError(
357
+ f"{label}: #{issue_number} has an unparseable annotation "
358
+ f"{{{raw}}}"
359
+ )
360
+ return group, after
361
+
362
+
363
+ def _parse_after_list(
364
+ issue_number: int, value: str, label: str = "work order"
365
+ ) -> list[int]:
366
+ items: list[int] = []
367
+ for chunk in value.split(","):
368
+ chunk = chunk.strip()
369
+ if not chunk:
370
+ continue
371
+ m = _AFTER_ITEM_RE.fullmatch(chunk)
372
+ if not m:
373
+ raise WorkOrderError(
374
+ f"{label}: #{issue_number} has a malformed after-entry "
375
+ f"{chunk!r} (expected '#N')"
376
+ )
377
+ items.append(int(m.group(1)))
378
+ return items
379
+
380
+
381
+ def render_work_order(work_order: WorkOrder, *, checkbox: bool = True) -> str:
382
+ """Render *work_order* back into checklist lines (no `## Work order` heading).
383
+
384
+ Inverse of the checklist half of :func:`parse_work_order` — round-trips
385
+ through it: ``parse_work_order(f"## Work order\\n{render_work_order(wo)}")
386
+ == wo``. Used by :func:`replace_work_order_section` and by
387
+ ``coord milestone write-order`` (#770) to persist a chat-proposed order.
388
+
389
+ Heading-agnostic (renders checklist lines only), so it doubles as the
390
+ render step for the `## Sub-issues` checklist (#1008) too — see the
391
+ :func:`render_sub_issues` alias.
392
+
393
+ *checkbox* defaults to ``True`` — preserves every existing caller's
394
+ output (notably ``coord milestone add-child``'s `## Sub-issues`
395
+ rendering) byte-for-byte. Pass ``checkbox=False`` for the #1061
396
+ checkbox-free grammar (`- #N {...}`, no `[ ]`/`[x]`) — what ``coord
397
+ milestone sync`` writes back, since the box was never read for
398
+ readiness (see the module docstring) and is being dropped rather than
399
+ kept in sync.
400
+ """
401
+ lines: list[str] = []
402
+ for n in work_order.nodes:
403
+ bits: list[str] = []
404
+ if n.group:
405
+ bits.append(f"group: {n.group}")
406
+ if n.after:
407
+ bits.append("after: " + ",".join(f"#{d}" for d in n.after))
408
+ annotation = f" {{{', '.join(bits)}}}" if bits else ""
409
+ box_prefix = f"[{'x' if n.checked else ' '}] " if checkbox else ""
410
+ lines.append(f"- {box_prefix}#{n.issue_number}{annotation}")
411
+ return "\n".join(lines)
412
+
413
+
414
+ # #1008: `render_work_order` already renders checklist lines only — no
415
+ # heading — so it's identical to what a `## Sub-issues` block needs. Aliased
416
+ # (rather than duplicated) so `coord milestone add-child` reads naturally
417
+ # alongside `parse_sub_issues` / `replace_sub_issues_section`.
418
+ render_sub_issues = render_work_order
419
+
420
+
421
+ def replace_work_order_section(body: str, new_block: str) -> str:
422
+ """Idempotently insert/replace the `## Work order` section of *body*.
423
+
424
+ ``new_block`` is checklist text only (e.g. :func:`render_work_order`'s
425
+ output) — no heading line. Mirrors :func:`parse_work_order`'s own
426
+ section-boundary rule so a round-trip through both functions agrees on
427
+ where the block starts and ends: if *body* already has a `## Work
428
+ order` heading, everything from the line after it up to the next
429
+ markdown heading (or EOF) is replaced in place, and everything else in
430
+ *body* is preserved verbatim — re-running with the same *new_block* is a
431
+ no-op, and re-running with a revised one updates rather than
432
+ duplicates. If *body* has no such heading, `## Work order\\n` +
433
+ *new_block* is appended at the end (blank-line separated).
434
+ """
435
+ return _splice_checklist_section(body, new_block, _HEADING_RE, "## Work order")
436
+
437
+
438
+ def replace_sub_issues_section(body: str, new_block: str) -> str:
439
+ """Idempotently insert/replace the `## Sub-issues` section of *body* (#1008).
440
+
441
+ Mirrors :func:`replace_work_order_section` exactly — same
442
+ splice-not-duplicate semantics — keyed on a `## Sub-issues` heading
443
+ instead of `## Work order`, so the two sections can coexist in one
444
+ tracking-issue body and each splice helper only ever touches its own
445
+ section. ``coord milestone add-child`` is the write path that calls
446
+ this (mirrors ``coord milestone write-order`` calling
447
+ :func:`replace_work_order_section`).
448
+ """
449
+ return _splice_checklist_section(
450
+ body, new_block, _SUB_ISSUES_HEADING_RE, "## Sub-issues"
451
+ )
452
+
453
+
454
+ def remove_sub_issues_section(body: str) -> str:
455
+ """Fully retire the `## Sub-issues` section of *body* (#1061).
456
+
457
+ The GitHub sub-issues API now owns child membership (:mod:`coord.
458
+ parentage_github`'s ``GitHubParentage``, backfilled per-epic by ``coord
459
+ milestone sync``) and `## Work order` already carries the same
460
+ issue-number list plus the DAG annotations the API can't express — so a
461
+ separately-maintained `## Sub-issues` checklist (#1008) is pure
462
+ duplication going forward. Unlike :func:`replace_sub_issues_section`
463
+ (which always keeps the heading line, even for an empty block, because
464
+ that function's job is "replace the content"), this drops the heading
465
+ line too, so nothing is left behind for a later `parse_sub_issues` call
466
+ to find. No-op — returns *body* unchanged — when there's no `##
467
+ Sub-issues` heading to remove.
468
+ """
469
+ lines = body.splitlines()
470
+ start = None
471
+ for i, line in enumerate(lines):
472
+ if _SUB_ISSUES_HEADING_RE.match(line.strip()):
473
+ start = i
474
+ break
475
+ if start is None:
476
+ return body
477
+
478
+ end = len(lines)
479
+ for i in range(start + 1, len(lines)):
480
+ if lines[i].strip().startswith("#"):
481
+ end = i
482
+ break
483
+
484
+ # Also swallow one blank separator line immediately before the heading
485
+ # (the blank line `_splice_checklist_section` inserts between sections)
486
+ # so removal doesn't leave a stray double-blank gap behind.
487
+ head_start = start - 1 if start > 0 and not lines[start - 1].strip() else start
488
+
489
+ new_lines = lines[:head_start] + lines[end:]
490
+ result = "\n".join(new_lines).rstrip("\n")
491
+ return f"{result}\n" if result else ""
492
+
493
+
494
+ def _splice_checklist_section(
495
+ body: str, new_block: str, heading_re: re.Pattern[str], heading_line: str
496
+ ) -> str:
497
+ """Shared implementation behind :func:`replace_work_order_section` /
498
+ :func:`replace_sub_issues_section`."""
499
+ lines = body.splitlines()
500
+ start = None
501
+ for i, line in enumerate(lines):
502
+ if heading_re.match(line.strip()):
503
+ start = i + 1
504
+ break
505
+
506
+ new_block_lines = new_block.strip("\n").splitlines() if new_block.strip() else []
507
+
508
+ if start is None:
509
+ prefix = body.rstrip("\n")
510
+ sep = "\n\n" if prefix else ""
511
+ rendered = "\n".join([heading_line, *new_block_lines])
512
+ return f"{prefix}{sep}{rendered}\n"
513
+
514
+ end = len(lines)
515
+ for i in range(start, len(lines)):
516
+ if lines[i].strip().startswith("#"):
517
+ end = i
518
+ break
519
+
520
+ tail = lines[end:]
521
+ if new_block_lines and tail and tail[0].strip():
522
+ tail = ["", *tail]
523
+
524
+ new_lines = lines[:start] + new_block_lines + tail
525
+ return "\n".join(new_lines).rstrip("\n") + "\n"
526
+
527
+
528
+ def _check_cycles(nodes: list[WorkOrderNode], label: str = "work order") -> None:
529
+ by_number = {n.issue_number: n for n in nodes}
530
+ WHITE, GRAY, BLACK = 0, 1, 2
531
+ color = {n.issue_number: WHITE for n in nodes}
532
+
533
+ def visit(num: int, path: list[int]) -> None:
534
+ color[num] = GRAY
535
+ path.append(num)
536
+ for dep in by_number[num].after:
537
+ if color[dep] == GRAY:
538
+ cycle = path[path.index(dep):] + [dep]
539
+ raise WorkOrderError(
540
+ f"{label}: dependency cycle: "
541
+ + " -> ".join(f"#{n}" for n in cycle)
542
+ )
543
+ if color[dep] == WHITE:
544
+ visit(dep, path)
545
+ path.pop()
546
+ color[num] = BLACK
547
+
548
+ for n in nodes:
549
+ if color[n.issue_number] == WHITE:
550
+ visit(n.issue_number, [])
551
+
552
+
553
+ def validate_milestone_membership(
554
+ work_order: WorkOrder,
555
+ milestone_issue_numbers: set[int],
556
+ ) -> None:
557
+ """Raise :class:`WorkOrderError` if a node isn't an issue under the milestone.
558
+
559
+ ``milestone_issue_numbers`` is the set of issue numbers the caller has
560
+ confirmed belong to the target milestone (open *or* closed — milestone
561
+ membership doesn't change when an issue closes, and a completed
562
+ dependency is an expected, valid node). Fetching that set is the
563
+ caller's job (``coord.github_ops`` / ``coord milestone order``) so this
564
+ stays a pure function tests can call with a plain seeded set.
565
+ """
566
+ for n in work_order.nodes:
567
+ if n.issue_number not in milestone_issue_numbers:
568
+ raise WorkOrderError(
569
+ f"work order: #{n.issue_number} is not an issue under this "
570
+ "milestone"
571
+ )
572
+
573
+
574
+ # ── Ready frontier ───────────────────────────────────────────────────────────
575
+
576
+
577
+ @dataclass(frozen=True)
578
+ class FrontierEntry:
579
+ """A node eligible to dispatch right now."""
580
+
581
+ issue_number: int
582
+ group: str | None = None
583
+
584
+
585
+ @dataclass(frozen=True)
586
+ class BlockedNode:
587
+ """A node that is not yet ready, and why."""
588
+
589
+ issue_number: int
590
+ waiting_on_deps: tuple[int, ...] = field(default_factory=tuple)
591
+ claim: Claim | None = None
592
+ conflict: bool = False
593
+
594
+ @property
595
+ def reason(self) -> str:
596
+ if self.waiting_on_deps:
597
+ deps = ", ".join(f"#{d}" for d in self.waiting_on_deps)
598
+ return f"waiting on {deps}"
599
+ if self.claim is not None:
600
+ return f"claimed ({self.claim.source})"
601
+ if self.conflict:
602
+ return "conflict-blocked"
603
+ return "blocked"
604
+
605
+
606
+ @dataclass(frozen=True)
607
+ class Frontier:
608
+ """The result of :func:`ready_frontier`: what can dispatch now, and what can't."""
609
+
610
+ ready: tuple[FrontierEntry, ...] = field(default_factory=tuple)
611
+ blocked: tuple[BlockedNode, ...] = field(default_factory=tuple)
612
+
613
+
614
+ def ready_frontier(
615
+ work_order: WorkOrder,
616
+ board: Board,
617
+ *,
618
+ repo_name: str,
619
+ repo_github: str,
620
+ terminal_issues: set[int],
621
+ branch_lookup: BranchLookup | None = None,
622
+ conflict_checker: Callable[[int], bool] | None = None,
623
+ ) -> Frontier:
624
+ """Compute the ready frontier of ``work_order`` given the current board.
625
+
626
+ A node is **ready** when:
627
+ 1. it hasn't itself already reached a merged/terminal state
628
+ (``issue_number not in terminal_issues``);
629
+ 2. every issue in its ``after`` set has (``after`` ⊆ ``terminal_issues``);
630
+ 3. it isn't already claimed — reuses :func:`coord.claim.find_work_claim`
631
+ against the live ``board`` (+ remote branch lookup);
632
+ 4. it isn't conflict-blocked — ``conflict_checker(issue_number)``, when
633
+ given, returning ``True`` means "another in-flight assignment likely
634
+ touches the same files." No default conflict inference exists yet
635
+ (today it's an LLM judgment made in ``coord.brain.propose``, not a
636
+ pure function) — omit ``conflict_checker`` to skip this check.
637
+
638
+ Nodes already in ``terminal_issues`` are dropped from both ``ready`` and
639
+ ``blocked`` — they're finished work, not part of the frontier either
640
+ way. Pure function: no GitHub or subprocess calls (``find_work_claim``'s
641
+ remote-branch check is injected via ``branch_lookup``, defaulting to the
642
+ live `gh` lookup only when the caller doesn't supply one).
643
+ """
644
+ ready: list[FrontierEntry] = []
645
+ blocked: list[BlockedNode] = []
646
+ for node in work_order.nodes:
647
+ if node.issue_number in terminal_issues:
648
+ continue
649
+
650
+ waiting = tuple(d for d in node.after if d not in terminal_issues)
651
+ if waiting:
652
+ blocked.append(BlockedNode(node.issue_number, waiting_on_deps=waiting))
653
+ continue
654
+
655
+ claim = find_work_claim(
656
+ node.issue_number,
657
+ repo_name,
658
+ repo_github,
659
+ board,
660
+ branch_lookup=branch_lookup,
661
+ )
662
+ if claim is not None:
663
+ blocked.append(BlockedNode(node.issue_number, claim=claim))
664
+ continue
665
+
666
+ if conflict_checker is not None and conflict_checker(node.issue_number):
667
+ blocked.append(BlockedNode(node.issue_number, conflict=True))
668
+ continue
669
+
670
+ ready.append(FrontierEntry(node.issue_number, node.group))
671
+
672
+ return Frontier(ready=tuple(ready), blocked=tuple(blocked))
673
+
674
+
675
+ # ── Progress (#1412) ─────────────────────────────────────────────────────────
676
+ # A *derived* section, never hand-authored: `coord milestone sync-progress`
677
+ # (one-shot) and the daemon tick (`coord.serve_app._milestone_progress_tick`,
678
+ # every registered milestone) render it from exactly the same terminal-state +
679
+ # `ready_frontier` inputs `coord milestone order` already prints, then splice
680
+ # it into the tracking issue under its own `## Progress` heading — `##
681
+ # Work order` (and, transitionally, `## Sub-issues`) are never touched.
682
+
683
+ _PROGRESS_HEADING_RE = re.compile(r"^#{1,6}\s*Progress\s*$", re.IGNORECASE)
684
+ _PROGRESS_ITEM_RE = re.compile(
685
+ r"^-\s*\[(?P<status>[a-zA-Z-]+)\]\s*#(?P<num>\d+)"
686
+ r"(?:\s*\(group\s+(?P<group>[^)]+)\))?"
687
+ r"(?:\s*—\s*(?P<detail>.+))?\s*$"
688
+ )
689
+
690
+
691
+ @dataclass(frozen=True)
692
+ class ProgressStatus:
693
+ """One `## Work order` node's derived, live status (#1412).
694
+
695
+ ``status`` is one of ``"done"`` (the issue is in ``terminal_issues``),
696
+ ``"ready"`` (in :func:`ready_frontier`'s ``ready`` set right now), or
697
+ ``"blocked"`` (in its ``blocked`` set — ``detail`` carries *why*, taken
698
+ verbatim from :attr:`BlockedNode.reason`: waiting on a dependency,
699
+ claimed, or conflict-blocked). There is no fourth bucket: every
700
+ non-terminal node in a work order is, by construction, either ready or
701
+ blocked (:func:`ready_frontier` partitions its whole input that way).
702
+ """
703
+
704
+ issue_number: int
705
+ status: str
706
+ group: str | None = None
707
+ detail: str | None = None
708
+
709
+
710
+ def compute_progress(
711
+ work_order: WorkOrder,
712
+ frontier: Frontier,
713
+ terminal_issues: set[int] | frozenset[int],
714
+ ) -> tuple[ProgressStatus, ...]:
715
+ """Derive one :class:`ProgressStatus` per *work_order* node.
716
+
717
+ Pure — takes the same ``terminal_issues`` and a *frontier* already
718
+ computed by :func:`ready_frontier` (which itself needs a live
719
+ :class:`~coord.models.Board`) rather than computing either itself, so
720
+ this stays exactly as easy to unit-test as the rest of the module and
721
+ never becomes a second place that decides what's ready. Iterates
722
+ ``work_order.nodes`` (not the frontier) so every declared node gets a
723
+ status line, in declared order, even ones the frontier dropped for
724
+ being terminal.
725
+ """
726
+ ready_numbers = {e.issue_number for e in frontier.ready}
727
+ blocked_by_number = {b.issue_number: b for b in frontier.blocked}
728
+ statuses: list[ProgressStatus] = []
729
+ for n in work_order.nodes:
730
+ if n.issue_number in terminal_issues:
731
+ statuses.append(ProgressStatus(n.issue_number, "done", n.group))
732
+ elif n.issue_number in ready_numbers:
733
+ statuses.append(ProgressStatus(n.issue_number, "ready", n.group))
734
+ else:
735
+ blocked = blocked_by_number.get(n.issue_number)
736
+ detail = blocked.reason if blocked is not None else "blocked"
737
+ statuses.append(
738
+ ProgressStatus(n.issue_number, "blocked", n.group, detail)
739
+ )
740
+ return tuple(statuses)
741
+
742
+
743
+ def render_progress(statuses: Iterable[ProgressStatus], *, generated_at: str) -> str:
744
+ """Render *statuses* into the `## Progress` section body (no heading).
745
+
746
+ ``generated_at`` is an ISO-8601 timestamp string the caller stamps at
747
+ write time (kept out of this pure function's control so it's trivial to
748
+ assert exact output in a test) — labelled explicitly as generated so a
749
+ reader never mistakes this for a second hand-editable plan. One `- [done|
750
+ ready|blocked] #N (group G) — detail` line per status, in the order
751
+ given, plus a one-line summary. :func:`parse_progress` is the inverse of
752
+ the per-status lines (the summary line and the generated-by note are
753
+ deliberately not round-tripped — they're always fully re-derived from
754
+ the statuses, never compared for the idempotent-no-op check).
755
+ """
756
+ statuses = tuple(statuses)
757
+ lines = [
758
+ "_Generated by `coord milestone sync-progress` from live board state "
759
+ f"as of {generated_at} — do not hand-edit; the plan itself lives in "
760
+ "`## Work order`._",
761
+ "",
762
+ ]
763
+ for s in statuses:
764
+ bits = [f"#{s.issue_number}"]
765
+ if s.group:
766
+ bits.append(f"(group {s.group})")
767
+ line = f"- [{s.status}] " + " ".join(bits)
768
+ if s.detail:
769
+ line += f" — {s.detail}"
770
+ lines.append(line)
771
+
772
+ if statuses:
773
+ done = sum(1 for s in statuses if s.status == "done")
774
+ ready = sum(1 for s in statuses if s.status == "ready")
775
+ blocked = sum(1 for s in statuses if s.status == "blocked")
776
+ lines.append("")
777
+ lines.append(
778
+ f"**{done}/{len(statuses)} done** · {ready} ready · {blocked} blocked"
779
+ )
780
+ return "\n".join(lines)
781
+
782
+
783
+ def parse_progress(body: str) -> tuple[ProgressStatus, ...]:
784
+ """Parse the `## Progress` section of *body* back into status entries.
785
+
786
+ Only used to detect whether re-rendering would actually change anything
787
+ (the idempotent-no-op check ``coord milestone sync-progress``/the daemon
788
+ tick perform before writing — see the module docstring) — never for
789
+ readiness; that always comes from a fresh :func:`ready_frontier` call.
790
+ Ignores the generated-by note and the summary line (neither is a status
791
+ line), and — like :func:`_parse_checklist_section` — stops at the next
792
+ markdown heading. Returns an empty tuple when *body* has no `##
793
+ Progress` heading. Unlike :func:`parse_work_order`/:func:`parse_sub_issues`,
794
+ an unparseable line is silently skipped rather than raising: this
795
+ section is machine-written, never hand-authored, so a stray line here
796
+ is not a user error to report — worst case it's simply not compared.
797
+ """
798
+ lines = body.splitlines()
799
+ start = None
800
+ for i, line in enumerate(lines):
801
+ if _PROGRESS_HEADING_RE.match(line.strip()):
802
+ start = i + 1
803
+ break
804
+ if start is None:
805
+ return ()
806
+
807
+ statuses: list[ProgressStatus] = []
808
+ for line in lines[start:]:
809
+ stripped = line.strip()
810
+ if not stripped:
811
+ continue
812
+ if stripped.startswith("#"):
813
+ break
814
+ m = _PROGRESS_ITEM_RE.match(stripped)
815
+ if not m:
816
+ continue
817
+ statuses.append(
818
+ ProgressStatus(
819
+ issue_number=int(m.group("num")),
820
+ status=m.group("status"),
821
+ group=m.group("group"),
822
+ detail=m.group("detail"),
823
+ )
824
+ )
825
+ return tuple(statuses)
826
+
827
+
828
+ def replace_progress_section(body: str, new_block: str) -> str:
829
+ """Idempotently insert/replace the `## Progress` section of *body* (#1412).
830
+
831
+ Mirrors :func:`replace_work_order_section`/:func:`replace_sub_issues_section`
832
+ exactly — same splice-not-duplicate semantics, keyed on a `## Progress`
833
+ heading — so this section coexists with `## Work order` and (during the
834
+ #1061 migration) `## Sub-issues` without any of the three splice helpers
835
+ ever touching another's region. ``new_block`` is
836
+ :func:`render_progress`'s output (checklist text only, no heading).
837
+ """
838
+ return _splice_checklist_section(
839
+ body, new_block, _PROGRESS_HEADING_RE, "## Progress"
840
+ )