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/comments.py ADDED
@@ -0,0 +1,718 @@
1
+ """Format and parse coordinator-authored issue comments.
2
+
3
+ Comments are the message bus between the coordinator and human reviewers. They
4
+ need to be readable in GitHub's UI *and* parseable by future automation, so
5
+ each comment carries an HTML-comment marker with key=value metadata.
6
+
7
+ Marker grammar: `<!-- coord:event=<event> assignment=<id> ... -->`
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass
15
+ from typing import Iterable
16
+
17
+ EVENT_BRIEFING = "briefing"
18
+ EVENT_COMPLETION = "completion"
19
+ EVENT_FAILURE = "failure"
20
+ EVENT_STUCK = "stuck"
21
+ EVENT_PLAN = "plan"
22
+ EVENT_ADVISORY = "advisory"
23
+ # #846: an assignment running past its wall-clock threshold, or thrashing
24
+ # through fix/review rounds without converging. Distinct from EVENT_STUCK
25
+ # (a worker self-reported STATUS/STUCK line) — this fires from time/round
26
+ # based detection, which also catches the "silently burned budget, looked
27
+ # productive the whole time" failure mode STUCK: lines miss (see #448).
28
+ EVENT_NEEDS_ATTENTION = "needs_attention"
29
+ # #1441: a pipeline row whose auto-loop TRANSITION already fired (a review
30
+ # completed, a fix completed) but which is now stuck on a precondition that
31
+ # landed too late for that one-shot reaction to see — e.g. a test verdict
32
+ # that arrives after the review it should have unblocked was already
33
+ # processed. Distinct from EVENT_NEEDS_ATTENTION (which watches *running*
34
+ # assignments for a wall-clock/convergence signal) — this watches *done*
35
+ # work chains for a downstream step that should have happened by now and
36
+ # didn't.
37
+ EVENT_STALLED = "stalled_pipeline"
38
+ # #2048: N consecutive BLOCKED verdicts from the cheap per-turn liveness
39
+ # auditor (coord/liveness_auditor.py). Distinct from EVENT_STUCK (self-
40
+ # reported) and EVENT_NEEDS_ATTENTION (wall-clock/round-count, no
41
+ # judgment) — this is an independent small-model *opinion* raised on a
42
+ # per-turn cadence, cheap enough to run continuously. Detection +
43
+ # surfacing only, exactly like EVENT_NEEDS_ATTENTION/EVENT_STALLED: never
44
+ # gates status/review_state/test_state.
45
+ EVENT_LIVENESS_STALL = "liveness_stall"
46
+
47
+
48
+ @dataclass
49
+ class CommentMarker:
50
+ event: str
51
+ fields: dict[str, str]
52
+
53
+
54
+ _MARKER_RE = re.compile(r"<!--\s*coord:(?P<body>[^>]*?)\s*-->")
55
+ _FIELD_RE = re.compile(r"(\w+)=(\S+)")
56
+
57
+ # ── Review-findings block ────────────────────────────────────────────────────
58
+ # The FULL review body, embedded in a completion comment under a parseable
59
+ # marker so a fix worker can recover it from the GitHub message bus on ANY
60
+ # machine (no shared DB required) — keyed by the review assignment id.
61
+ FINDINGS_BEGIN = "coord:review-findings"
62
+ FINDINGS_END = "coord:review-findings-end"
63
+
64
+
65
+ def format_findings_block(assignment_id: str, verdict: str | None, body: str) -> str:
66
+ """Render the full review findings as a marked, GitHub-readable block."""
67
+ v = f" verdict={verdict}" if verdict else ""
68
+ return (
69
+ f"<!-- {FINDINGS_BEGIN} assignment={assignment_id}{v} -->\n"
70
+ f"### Review findings\n\n{body.strip()}\n"
71
+ f"<!-- {FINDINGS_END} -->"
72
+ )
73
+
74
+
75
+ def extract_findings_block(
76
+ comment_body: str, assignment_id: str
77
+ ) -> tuple[str | None, str] | None:
78
+ """Return ``(verdict, body)`` from a comment carrying the marked findings
79
+ block for ``assignment_id``, or ``None`` when absent. ``verdict`` is read
80
+ from the marker and may be ``None`` if it wasn't recorded."""
81
+ pat = re.compile(
82
+ r"<!--\s*" + re.escape(FINDINGS_BEGIN) + r"\s+assignment="
83
+ + re.escape(assignment_id) + r"(?P<attrs>[^>]*)-->\s*(?P<body>.*?)\s*<!--\s*"
84
+ + re.escape(FINDINGS_END) + r"\s*-->",
85
+ re.DOTALL,
86
+ )
87
+ m = pat.search(comment_body or "")
88
+ if not m:
89
+ return None
90
+ vm = re.search(r"verdict=(\S+)", m.group("attrs") or "")
91
+ verdict = vm.group(1) if vm else None
92
+ body = re.sub(r"^#+\s*Review findings\s*\n+", "", m.group("body").strip(), count=1)
93
+ body = body.strip()
94
+ if not body:
95
+ return None
96
+ return (verdict, body)
97
+
98
+
99
+ # ── Milestone Outcome Audit scorecard (#886 Phase 2) ────────────────────────
100
+ # The structured, versioned verdict from a `--audit-of` run (#885), embedded
101
+ # under a parseable marker so ANY machine can recover the full goal-by-goal
102
+ # JSON from the GitHub message bus alone — the same "no shared DB required"
103
+ # design as the review-findings block above.
104
+ AUDIT_BEGIN = "coord:audit-scorecard"
105
+ AUDIT_END = "coord:audit-scorecard-end"
106
+
107
+
108
+ def format_audit_scorecard(
109
+ *,
110
+ assignment_id: str,
111
+ run_number: int,
112
+ bottom_line: str,
113
+ goals: list[dict],
114
+ diff: dict[str, list[str]] | None = None,
115
+ ) -> str:
116
+ """Render a milestone-audit scorecard: a human-readable table + delta vs
117
+ the prior run, with the raw goal JSON tucked in a collapsible section so
118
+ a future agent (or `extract_audit_scorecard`) can recover it exactly."""
119
+
120
+ def _cell(v: object) -> str:
121
+ return str(v if v is not None else "").replace("|", "\\|").replace("\n", " ")
122
+
123
+ table = ["| Goal | Before | After | Verdict | Evidence |", "|---|---|---|---|---|"]
124
+ for g in goals:
125
+ table.append(
126
+ f"| {_cell(g.get('goal'))} | {_cell(g.get('metric_before'))} | "
127
+ f"{_cell(g.get('metric_after'))} | {_cell(g.get('verdict'))} | "
128
+ f"{_cell(g.get('evidence'))} |"
129
+ )
130
+ lines = [
131
+ f"### Milestone outcome audit — run v{run_number}",
132
+ f"<!-- {AUDIT_BEGIN} assignment={assignment_id} run={run_number} -->",
133
+ "",
134
+ f"**Bottom line:** {bottom_line or '(none provided)'}",
135
+ "",
136
+ *table,
137
+ ]
138
+ if diff and any(diff.get(k) for k in ("closed", "regressed", "still_open", "new")):
139
+ lines.append("")
140
+ lines.append(f"**Delta vs run v{run_number - 1}:**")
141
+ if diff.get("closed"):
142
+ lines.append(f"- ✅ closed: {', '.join(diff['closed'])}")
143
+ if diff.get("regressed"):
144
+ lines.append(f"- ⚠️ regressed: {', '.join(diff['regressed'])}")
145
+ if diff.get("still_open"):
146
+ lines.append(f"- still open: {', '.join(diff['still_open'])}")
147
+ if diff.get("new"):
148
+ lines.append(f"- new goals: {', '.join(diff['new'])}")
149
+ lines.append("")
150
+ lines.append("<details><summary>Structured verdict (JSON)</summary>")
151
+ lines.append("")
152
+ lines.append("```json")
153
+ lines.append(json.dumps(goals, indent=2))
154
+ lines.append("```")
155
+ lines.append("")
156
+ lines.append("</details>")
157
+ lines.append(f"<!-- {AUDIT_END} -->")
158
+ return "\n".join(lines)
159
+
160
+
161
+ def extract_audit_scorecard(comment_body: str) -> dict | None:
162
+ """Parse a marked audit scorecard back into
163
+ ``{"assignment_id", "run_number", "bottom_line", "goals"}``, or ``None``
164
+ if the comment carries no scorecard block. Round-trip counterpart to
165
+ :func:`format_audit_scorecard`."""
166
+ pat = re.compile(
167
+ r"<!--\s*" + re.escape(AUDIT_BEGIN)
168
+ + r"\s+assignment=(?P<aid>\S+)\s+run=(?P<run>\d+)\s*-->"
169
+ r"(?P<body>.*?)<!--\s*" + re.escape(AUDIT_END) + r"\s*-->",
170
+ re.DOTALL,
171
+ )
172
+ m = pat.search(comment_body or "")
173
+ if not m:
174
+ return None
175
+ body = m.group("body")
176
+ bl_m = re.search(r"\*\*Bottom line:\*\*\s*(.+)", body)
177
+ bottom_line = bl_m.group(1).strip() if bl_m else ""
178
+ json_m = re.search(r"```json\s*(\[.*?\])\s*```", body, re.DOTALL)
179
+ try:
180
+ goals = json.loads(json_m.group(1)) if json_m else []
181
+ except (TypeError, ValueError):
182
+ goals = []
183
+ return {
184
+ "assignment_id": m.group("aid"),
185
+ "run_number": int(m.group("run")),
186
+ "bottom_line": bottom_line,
187
+ "goals": goals,
188
+ }
189
+
190
+
191
+ def _marker(event: str, **fields: str | int | None) -> str:
192
+ parts = [f"event={event}"]
193
+ for k, v in fields.items():
194
+ if v is None or v == "":
195
+ continue
196
+ parts.append(f"{k}={v}")
197
+ return f"<!-- coord:{' '.join(parts)} -->"
198
+
199
+
200
+ def parse_marker(body: str) -> CommentMarker | None:
201
+ """Return the first coord marker in a comment body, or None."""
202
+ m = _MARKER_RE.search(body)
203
+ if not m:
204
+ return None
205
+ raw = m.group("body")
206
+ fields = dict(_FIELD_RE.findall(raw))
207
+ event = fields.pop("event", "")
208
+ if not event:
209
+ return None
210
+ return CommentMarker(event=event, fields=fields)
211
+
212
+
213
+ # ── Unified parse for durable storage (#873) ─────────────────────────────────
214
+ # coord.review posts a differently-shaped header — `<!-- coord:review
215
+ # verdict=... -->` (no `event=` token) — which parse_marker() above doesn't
216
+ # recognise (it requires `event=`). A self-contained regex is duplicated here
217
+ # rather than importing coord.review, to avoid a circular import: github_ops
218
+ # (the #873 capture-at-write choke point) needs coord.state, and coord.review
219
+ # already imports coord.github_ops.
220
+ _REVIEW_MARKER_RE = re.compile(r"<!--\s*coord:review\s+([^>]+?)\s*-->")
221
+
222
+
223
+ def parse_coord_comment_marker(body: str) -> dict[str, str | None] | None:
224
+ """Unified marker parse across all coord comment grammars, for the
225
+ ``issue_comments`` durable mirror (#873).
226
+
227
+ Tries the generic ``coord:event=...`` marker first (covers
228
+ briefing/completion/failure/stuck/plan/advisory/needs_attention), then
229
+ falls back to the ``coord:review verdict=...`` header format used by
230
+ posted review bodies. Returns ``{"event", "assignment_id", "machine",
231
+ "verdict"}`` (any value may be ``None``), or ``None`` when *body* carries
232
+ no recognisable coord marker at all.
233
+ """
234
+ marker = parse_marker(body)
235
+ if marker is not None:
236
+ return {
237
+ "event": marker.event,
238
+ "assignment_id": marker.fields.get("assignment"),
239
+ "machine": marker.fields.get("machine"),
240
+ "verdict": marker.fields.get("verdict"),
241
+ }
242
+ m = _REVIEW_MARKER_RE.search(body or "")
243
+ if not m:
244
+ return None
245
+ fields = dict(_FIELD_RE.findall(m.group(1)))
246
+ if "verdict" not in fields:
247
+ return None
248
+ return {
249
+ "event": "review",
250
+ "assignment_id": fields.get("assignment"),
251
+ "machine": fields.get("reviewer"),
252
+ "verdict": fields.get("verdict"),
253
+ }
254
+
255
+
256
+ def _fmt_files(files: Iterable[str]) -> str:
257
+ files = list(files)
258
+ if not files:
259
+ return "(unspecified)"
260
+ return ", ".join(f"`{f}`" for f in files)
261
+
262
+
263
+ def _fmt_duration(seconds: float | None) -> str:
264
+ if seconds is None:
265
+ return "—"
266
+ seconds = int(seconds)
267
+ if seconds < 60:
268
+ return f"{seconds}s"
269
+ m, s = divmod(seconds, 60)
270
+ if m < 60:
271
+ return f"{m}m {s}s"
272
+ h, m = divmod(m, 60)
273
+ return f"{h}h {m}m"
274
+
275
+
276
+ def format_briefing(
277
+ *,
278
+ assignment_id: str,
279
+ machine_name: str,
280
+ repo_name: str,
281
+ issue_number: int,
282
+ briefing: str,
283
+ files_likely: Iterable[str] = (),
284
+ do_not_touch: Iterable[tuple[str, str]] = (),
285
+ ) -> str:
286
+ """Build the issue comment posted when an assignment is dispatched.
287
+
288
+ `do_not_touch` is a sequence of (file_path, reason) pairs describing files
289
+ other workers are currently touching.
290
+ """
291
+ marker = _marker(
292
+ EVENT_BRIEFING,
293
+ assignment=assignment_id,
294
+ machine=machine_name,
295
+ repo=repo_name,
296
+ issue=issue_number,
297
+ )
298
+ lines = [
299
+ "## Coordinator Assignment",
300
+ marker,
301
+ f"**Machine:** {machine_name}",
302
+ f"**Repo:** {repo_name}",
303
+ f"**Files:** {_fmt_files(files_likely)}",
304
+ ]
305
+ dnt = list(do_not_touch)
306
+ if dnt:
307
+ rendered = ", ".join(f"`{path}` ({reason})" for path, reason in dnt)
308
+ lines.append(f"**Do not touch:** {rendered}")
309
+ else:
310
+ lines.append("**Do not touch:** (nothing else in flight)")
311
+ lines.append("")
312
+ lines.append("### Briefing")
313
+ lines.append(briefing.strip() or "(no briefing provided)")
314
+ return "\n".join(lines)
315
+
316
+
317
+ def format_completion(
318
+ *,
319
+ assignment_id: str,
320
+ machine_name: str,
321
+ repo_name: str,
322
+ issue_number: int,
323
+ exit_code: int,
324
+ duration_seconds: float | None = None,
325
+ log_path: str | None = None,
326
+ summary: str = "",
327
+ ) -> str:
328
+ marker = _marker(
329
+ EVENT_COMPLETION,
330
+ assignment=assignment_id,
331
+ machine=machine_name,
332
+ repo=repo_name,
333
+ issue=issue_number,
334
+ exit_code=exit_code,
335
+ )
336
+ lines = [
337
+ "## Coordinator: Assignment Complete",
338
+ marker,
339
+ f"**Machine:** {machine_name}",
340
+ f"**Status:** done",
341
+ f"**Exit code:** {exit_code}",
342
+ f"**Duration:** {_fmt_duration(duration_seconds)}",
343
+ ]
344
+ if log_path:
345
+ lines.append(f"**Log:** `{log_path}`")
346
+ if summary.strip():
347
+ lines.append("")
348
+ lines.append("### Summary")
349
+ lines.append(summary.strip())
350
+ return "\n".join(lines)
351
+
352
+
353
+ def format_stuck(
354
+ *,
355
+ assignment_id: str,
356
+ machine_name: str,
357
+ repo_name: str,
358
+ issue_number: int,
359
+ stuck_message: str,
360
+ ) -> str:
361
+ marker = _marker(
362
+ EVENT_STUCK,
363
+ assignment=assignment_id,
364
+ machine=machine_name,
365
+ repo=repo_name,
366
+ )
367
+ lines = [
368
+ marker,
369
+ f"## ⚠️ Worker STUCK",
370
+ f"**Machine:** {machine_name}",
371
+ f"**Assignment:** {assignment_id}",
372
+ f"**Issue:** #{issue_number}",
373
+ "",
374
+ stuck_message.strip(),
375
+ "",
376
+ "The worker has stopped and is waiting for guidance. Use:",
377
+ f"`coord resume-stuck {assignment_id} --guidance \"your answer here\"`",
378
+ ]
379
+ return "\n".join(lines)
380
+
381
+
382
+ def format_needs_attention(
383
+ *,
384
+ assignment_id: str,
385
+ machine_name: str,
386
+ repo_name: str,
387
+ issue_number: int,
388
+ reason: str,
389
+ detail: str,
390
+ ) -> str:
391
+ """Format a "needs attention" comment (#846) — a time/round-based signal
392
+ that an assignment is running long or thrashing, distinct from a
393
+ worker's self-reported ``STUCK:`` line.
394
+
395
+ *reason* is ``"wall_clock"`` or ``"non_convergence"``; *detail* is a
396
+ human-readable one-liner (e.g. "running 52m, threshold 45m" or
397
+ "4 fix/review rounds without a green test + approved review").
398
+ """
399
+ marker = _marker(
400
+ EVENT_NEEDS_ATTENTION,
401
+ assignment=assignment_id,
402
+ machine=machine_name,
403
+ repo=repo_name,
404
+ reason=reason,
405
+ )
406
+ lines = [
407
+ marker,
408
+ "## ⏱ Needs attention",
409
+ f"**Machine:** {machine_name}",
410
+ f"**Assignment:** {assignment_id}",
411
+ f"**Issue:** #{issue_number}",
412
+ f"**Reason:** {'Running too long' if reason == 'wall_clock' else 'Not converging'}",
413
+ "",
414
+ detail.strip(),
415
+ "",
416
+ "Detection + surfacing only — nothing was killed or reassigned. A "
417
+ "human should take a look; use `coord log <id>` to see what it's "
418
+ "doing, or `coord retry`/`coord stop` to intervene.",
419
+ ]
420
+ return "\n".join(lines)
421
+
422
+
423
+ def format_liveness_stall(
424
+ *,
425
+ assignment_id: str,
426
+ machine_name: str,
427
+ repo_name: str,
428
+ issue_number: int,
429
+ consecutive_blocked: int,
430
+ ) -> str:
431
+ """Format a "liveness auditor" comment (#2048) — *consecutive_blocked*
432
+ independent per-turn audits in a row judged this worker's latest turn
433
+ as showing no progress toward the objective.
434
+
435
+ This is a cheap small-model *opinion*, not a correctness check — it
436
+ complements, and is deliberately weaker than, an adversarial review or
437
+ the sealed oracle suite. See ``coord/liveness_auditor.py``'s module
438
+ docstring.
439
+ """
440
+ marker = _marker(
441
+ EVENT_LIVENESS_STALL,
442
+ assignment=assignment_id,
443
+ machine=machine_name,
444
+ repo=repo_name,
445
+ strikes=consecutive_blocked,
446
+ )
447
+ lines = [
448
+ marker,
449
+ "## 🐢 Liveness auditor: possible stall",
450
+ f"**Machine:** {machine_name}",
451
+ f"**Assignment:** {assignment_id}",
452
+ f"**Issue:** #{issue_number}",
453
+ f"**Consecutive BLOCKED verdicts:** {consecutive_blocked}",
454
+ "",
455
+ (
456
+ "An independent, cheap small-model auditor judged this "
457
+ "worker's last few turns as showing no progress toward the "
458
+ "objective — repeating itself, confused, or spinning. It saw "
459
+ "only the objective and each turn's own output, never the "
460
+ "transcript or the worker's self-reported status."
461
+ ),
462
+ "",
463
+ "This is a tripwire, not a verdict: nothing was killed, gated, or "
464
+ "reassigned, and no board status changed. It runs continuously "
465
+ "for pennies and is meant to be wrong sometimes — a human should "
466
+ "take a look with `coord log <id>` and decide whether to "
467
+ "intervene.",
468
+ ]
469
+ return "\n".join(lines)
470
+
471
+
472
+ _STALLED_REASON_LABELS = {
473
+ "review_request_changes_no_fix": "Review requested changes, no fix dispatched",
474
+ "review_done_no_verdict": "Review finished with no verdict ever captured (#812)",
475
+ "done_no_review": "Work done, no review ever dispatched",
476
+ "approved_not_queued": "Approved + tested, but not queued for merge",
477
+ "merge_conflict_unresolved": "Merge entry stuck in CONFLICT, no fix attempted",
478
+ "review_failed_no_verdict": "Review worker failed before producing a verdict",
479
+ }
480
+
481
+
482
+ def format_stalled_pipeline(
483
+ *,
484
+ assignment_id: str,
485
+ machine_name: str,
486
+ repo_name: str,
487
+ issue_number: int,
488
+ reason: str,
489
+ detail: str,
490
+ ) -> str:
491
+ """Format a "stalled pipeline row" comment (#1441).
492
+
493
+ *reason* is one of ``"review_request_changes_no_fix"``,
494
+ ``"done_no_review"``, ``"approved_not_queued"``,
495
+ ``"merge_conflict_unresolved"``, or ``"review_failed_no_verdict"``
496
+ (#1584); *detail* is a human-readable one-liner naming the unmet
497
+ precondition.
498
+ """
499
+ marker = _marker(
500
+ EVENT_STALLED,
501
+ assignment=assignment_id,
502
+ machine=machine_name,
503
+ repo=repo_name,
504
+ reason=reason,
505
+ )
506
+ label = _STALLED_REASON_LABELS.get(reason, reason)
507
+ lines = [
508
+ marker,
509
+ "## 🧊 Pipeline row stalled",
510
+ f"**Machine:** {machine_name}",
511
+ f"**Assignment:** {assignment_id}",
512
+ f"**Issue:** #{issue_number}",
513
+ f"**Reason:** {label}",
514
+ "",
515
+ detail.strip(),
516
+ "",
517
+ "Detection + surfacing only — nothing was dispatched automatically. "
518
+ "This row's auto-loop transition already fired once and won't fire "
519
+ "again on its own; a human should look at `coord status` and "
520
+ "decide whether to dispatch a fix/review manually or drop it with "
521
+ "`coord backlog`.",
522
+ ]
523
+ return "\n".join(lines)
524
+
525
+
526
+ def format_stalled_pipeline_dispatch(
527
+ *,
528
+ assignment_id: str,
529
+ repo_name: str,
530
+ issue_number: int,
531
+ reason: str,
532
+ action_kind: str,
533
+ action_detail: str,
534
+ ) -> str:
535
+ """Format the #1478 auto-dispatch outcome comment for a stalled row.
536
+
537
+ Posted INSTEAD OF :func:`format_stalled_pipeline`'s diagnostic-only
538
+ comment when ``pipeline.auto_dispatch_stalled`` is on and
539
+ ``coord.notify.dispatch_stalled_pipeline_action`` actually dispatched
540
+ something for this row — posting both would leave a directly
541
+ contradictory "nothing was dispatched automatically" comment sitting
542
+ right above this one on the same issue thread.
543
+ """
544
+ marker = _marker(
545
+ EVENT_STALLED,
546
+ assignment=assignment_id,
547
+ repo=repo_name,
548
+ reason=reason,
549
+ auto_dispatch=action_kind,
550
+ )
551
+ label = _STALLED_REASON_LABELS.get(reason, reason)
552
+ lines = [
553
+ marker,
554
+ "## ⚙️ Stalled pipeline row auto-dispatched",
555
+ f"**Assignment:** {assignment_id}",
556
+ f"**Issue:** #{issue_number}",
557
+ f"**Stall reason:** {label}",
558
+ f"**Action taken:** `{action_kind}`",
559
+ "",
560
+ action_detail.strip(),
561
+ "",
562
+ "The coordinator's stalled-pipeline sweep (#1478, "
563
+ "`pipeline.auto_dispatch_stalled`) dispatched the action the "
564
+ "original auto-loop transition would have taken. This is "
565
+ "best-effort automation — check `coord status` if the outcome "
566
+ "looks wrong.",
567
+ ]
568
+ return "\n".join(lines)
569
+
570
+
571
+ def format_failure(
572
+ *,
573
+ assignment_id: str,
574
+ machine_name: str,
575
+ repo_name: str,
576
+ issue_number: int,
577
+ exit_code: int | None,
578
+ duration_seconds: float | None = None,
579
+ log_path: str | None = None,
580
+ error: str = "",
581
+ ) -> str:
582
+ marker = _marker(
583
+ EVENT_FAILURE,
584
+ assignment=assignment_id,
585
+ machine=machine_name,
586
+ repo=repo_name,
587
+ issue=issue_number,
588
+ exit_code=exit_code if exit_code is not None else "",
589
+ )
590
+ lines = [
591
+ "## Coordinator: Assignment Failed",
592
+ marker,
593
+ f"**Machine:** {machine_name}",
594
+ f"**Status:** failed",
595
+ f"**Exit code:** {exit_code if exit_code is not None else '—'}",
596
+ f"**Duration:** {_fmt_duration(duration_seconds)}",
597
+ ]
598
+ if log_path:
599
+ lines.append(f"**Log:** `{log_path}`")
600
+ if error.strip():
601
+ lines.append("")
602
+ lines.append("### Error")
603
+ lines.append(error.strip())
604
+ return "\n".join(lines)
605
+
606
+
607
+ def format_advisory(
608
+ *,
609
+ assignment_id: str,
610
+ machine_name: str,
611
+ repo_name: str,
612
+ issue_number: int,
613
+ duration_seconds: float | None = None,
614
+ log_path: str | None = None,
615
+ reason: str = "",
616
+ ) -> str:
617
+ """Format an advisory comment for a 0-commit clean exit.
618
+
619
+ Distinct from both completion (no code was produced) and failure (the
620
+ worker exited cleanly — exit_code 0 — so no error occurred). Human
621
+ review is needed to decide next steps.
622
+ """
623
+ marker = _marker(
624
+ EVENT_ADVISORY,
625
+ assignment=assignment_id,
626
+ machine=machine_name,
627
+ repo=repo_name,
628
+ issue=issue_number,
629
+ )
630
+ lines = [
631
+ "## Coordinator: Advisory — Worker Exited With 0 Commits",
632
+ marker,
633
+ f"**Machine:** {machine_name}",
634
+ f"**Status:** advisory",
635
+ f"**Duration:** {_fmt_duration(duration_seconds)}",
636
+ ]
637
+ if log_path:
638
+ lines.append(f"**Log:** `{log_path}`")
639
+ lines.append("")
640
+ lines.append(
641
+ "The worker exited cleanly (exit code 0) but pushed **no commits**. "
642
+ "This typically means the feature was already implemented or no change "
643
+ "was needed. Human review is required to decide the next step."
644
+ )
645
+ if reason.strip():
646
+ lines.append("")
647
+ lines.append("### Worker note")
648
+ lines.append(reason.strip())
649
+ return "\n".join(lines)
650
+
651
+
652
+ def format_plan(
653
+ *,
654
+ assignment_id: str,
655
+ machine_name: str,
656
+ repo_name: str,
657
+ issue_number: int,
658
+ plan: object, # coord.plan_parser.WorkerPlan (untyped to avoid circular import)
659
+ duration_seconds: float | None = None,
660
+ ) -> str:
661
+ """Build the issue comment posted when a plan-only assignment completes.
662
+
663
+ *plan* should be a :class:`coord.plan_parser.WorkerPlan` instance, but the
664
+ parameter is typed as ``object`` to avoid a circular import — callers that
665
+ already have the dataclass can pass it directly.
666
+ """
667
+ marker = _marker(
668
+ EVENT_PLAN,
669
+ assignment=assignment_id,
670
+ machine=machine_name,
671
+ repo=repo_name,
672
+ issue=issue_number,
673
+ )
674
+ lines = [
675
+ "## Coordinator: Implementation Plan",
676
+ marker,
677
+ f"**Machine:** {machine_name}",
678
+ f"**Duration:** {_fmt_duration(duration_seconds)}",
679
+ "",
680
+ ]
681
+
682
+ plan_text: str = getattr(plan, "plan", "") or ""
683
+ files_read: list[str] = getattr(plan, "files_read", []) or []
684
+ files_modify: list[str] = getattr(plan, "files_modify", []) or []
685
+ approach: str = getattr(plan, "approach", "") or ""
686
+ risks: str = getattr(plan, "risks", "") or ""
687
+ estimate: str = getattr(plan, "estimate", "") or ""
688
+
689
+ if plan_text.strip():
690
+ lines.append("### Summary")
691
+ lines.append(plan_text.strip())
692
+ lines.append("")
693
+
694
+ if files_read:
695
+ lines.append("### Files Read")
696
+ lines.append(", ".join(f"`{f}`" for f in files_read))
697
+ lines.append("")
698
+
699
+ if files_modify:
700
+ lines.append("### Files to Modify")
701
+ lines.append(", ".join(f"`{f}`" for f in files_modify))
702
+ lines.append("")
703
+
704
+ if approach.strip():
705
+ lines.append("### Approach")
706
+ lines.append(approach.strip())
707
+ lines.append("")
708
+
709
+ if risks.strip():
710
+ lines.append("### Risks")
711
+ lines.append(risks.strip())
712
+ lines.append("")
713
+
714
+ if estimate.strip():
715
+ lines.append("### Estimate")
716
+ lines.append(estimate.strip())
717
+
718
+ return "\n".join(lines)