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/progress.py ADDED
@@ -0,0 +1,425 @@
1
+ """Parse worker progress signals from log output.
2
+
3
+ Two paths are supported:
4
+
5
+ * **stream-json**: when the worker was launched with
6
+ ``--output-format stream-json --verbose`` (the new default), each log line
7
+ is a structured event. We delegate to the assignment's resolved
8
+ :class:`~coord.providers.base.Provider` (``provider.parse_log()`` — #1710),
9
+ defaulting to :class:`~coord.providers.claude.ClaudeProvider` (which itself
10
+ delegates to :mod:`coord.worker_events`) when no provider is known — so
11
+ ``parse_progress`` produces byte-identical output to before #1710 for every
12
+ existing (claude) caller that doesn't pass ``provider``/``provider_name``.
13
+ * **plain text** (legacy): we fall back to the old ``STATUS:``/``STUCK:``
14
+ regex scan for backwards compatibility with logs from older agents and
15
+ for non-claude worker commands used in tests. This path is
16
+ provider-agnostic by construction — any worker that emits ``STATUS:``/
17
+ ``STUCK:`` lines in plain text is handled the same way regardless of which
18
+ provider ran it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING
27
+
28
+ if TYPE_CHECKING:
29
+ from coord.providers.base import Provider
30
+
31
+
32
+ STATUS_RE = re.compile(r"^STATUS:\s*(.+)$", re.MULTILINE)
33
+ STUCK_RE = re.compile(r"^STUCK:\s*(.+)$", re.MULTILINE)
34
+ CONFIDENCE_RE = re.compile(r"confidence:\s*(high|medium|low)", re.IGNORECASE)
35
+
36
+ # #252: workers emit a SMOKE_TESTS block before exiting. The whole block
37
+ # is captured (greedy across newlines) and parsed below. Optional `(none
38
+ # — change is internal)` form folds to an empty list.
39
+ _SMOKE_BLOCK_RE = re.compile(
40
+ r"SMOKE_TESTS:\s*(.*?)\s*END_SMOKE_TESTS",
41
+ re.DOTALL | re.IGNORECASE,
42
+ )
43
+ _SMOKE_NONE_RE = re.compile(
44
+ r"^\(?\s*none\b.*?(?:internal|change)?\s*\)?\s*$",
45
+ re.IGNORECASE,
46
+ )
47
+ _SMOKE_BULLET_RE = re.compile(r"^\s*[-*]\s+(.+?)\s*$")
48
+
49
+
50
+ @dataclass
51
+ class WorkerProgress:
52
+ updates: list[str] = field(default_factory=list)
53
+ stuck: str | None = None
54
+ warnings: list[str] = field(default_factory=list)
55
+ latest_confidence: str | None = None
56
+
57
+ def to_dict(self) -> dict:
58
+ return {
59
+ "updates": self.updates,
60
+ "stuck": self.stuck,
61
+ "warnings": self.warnings,
62
+ "latest_confidence": self.latest_confidence,
63
+ }
64
+
65
+
66
+ def parse_progress(
67
+ log_path: str | Path,
68
+ tail_bytes: int = 32_768,
69
+ *,
70
+ provider_name: str | None = None,
71
+ provider: "Provider | None" = None,
72
+ ) -> WorkerProgress:
73
+ """Parse progress from a worker log.
74
+
75
+ Detects stream-json automatically and switches parsing strategies. Reads
76
+ only the tail of the log to stay cheap on large files.
77
+
78
+ Args:
79
+ log_path: Path to the worker's log file.
80
+ tail_bytes: Only the last *tail_bytes* of the file is read.
81
+ provider_name: The assignment's resolved provider name (e.g.
82
+ ``Assignment.provider_name``). ``None`` (the default) resolves to
83
+ :class:`~coord.providers.claude.ClaudeProvider` — the pre-#1710
84
+ behaviour — so existing callers that don't pass this are
85
+ unaffected.
86
+ provider: An already-constructed :class:`~coord.providers.base.Provider`
87
+ to use directly, bypassing name resolution entirely. Takes
88
+ precedence over *provider_name*. Mainly for tests that want to
89
+ exercise a specific (e.g. fake, non-claude) provider without
90
+ wiring up a full :class:`~coord.config.Config`.
91
+ """
92
+ from coord.worker_events import detect_anomalies, is_stream_json
93
+
94
+ p = Path(log_path)
95
+ if not p.exists():
96
+ return WorkerProgress()
97
+
98
+ if is_stream_json(p):
99
+ if provider is None:
100
+ from coord.providers import get_provider # noqa: PLC0415
101
+ provider = get_provider(provider_name)
102
+ summary = provider.parse_log(p, tail_bytes=tail_bytes)
103
+ progress = WorkerProgress()
104
+ # Synthesise a single rolling "update" line so coord status keeps
105
+ # showing recent activity for stream-json workers.
106
+ if summary.num_turns or summary.last_tool:
107
+ tool_part = summary.last_tool or "thinking"
108
+ progress.updates.append(f"Turn {summary.num_turns}: {tool_part}")
109
+ # Surface anomaly patterns as warnings.
110
+ progress.warnings.extend(detect_anomalies(p, tail_bytes=tail_bytes))
111
+ if summary.stop_reason and summary.stop_reason not in (
112
+ "end_turn",
113
+ "stop_sequence",
114
+ None,
115
+ ):
116
+ progress.warnings.append(f"unusual stop: {summary.stop_reason}")
117
+ return progress
118
+
119
+ # ── Plain-text fallback ───────────────────────────────────────────────
120
+ size = p.stat().st_size
121
+ with open(p) as f:
122
+ if size > tail_bytes:
123
+ f.seek(size - tail_bytes)
124
+ f.readline() # skip partial line
125
+ text = f.read()
126
+
127
+ updates = STATUS_RE.findall(text)
128
+ stuck_matches = STUCK_RE.findall(text)
129
+
130
+ progress = WorkerProgress(
131
+ updates=updates[-10:],
132
+ stuck=stuck_matches[-1] if stuck_matches else None,
133
+ )
134
+
135
+ # Extract latest confidence
136
+ if updates:
137
+ conf = CONFIDENCE_RE.search(updates[-1])
138
+ if conf:
139
+ progress.latest_confidence = conf.group(1).lower()
140
+
141
+ # Detect warning patterns
142
+ _detect_warnings(progress, updates)
143
+
144
+ return progress
145
+
146
+
147
+ def _extract_smoke_tests_from_text(text: str) -> list[str] | None:
148
+ """#252: pull the SMOKE_TESTS block out of *text*.
149
+
150
+ Returns:
151
+ * ``None`` — no block emitted (graceful degradation; the TUI shows
152
+ a "no smoke tests provided" placeholder).
153
+ * ``[]`` — explicit "(none — change is internal)" form.
154
+ * ``list[str]`` — one entry per bullet, stripped of leading "- "/"* ".
155
+
156
+ Tolerant of leading/trailing whitespace and stray empty lines. Picks
157
+ the LAST block in the text — workers occasionally redo their summary
158
+ if they reconsider the change.
159
+ """
160
+ matches = list(_SMOKE_BLOCK_RE.finditer(text))
161
+ if not matches:
162
+ return None
163
+ block = matches[-1].group(1)
164
+
165
+ # Try the "(none — change is internal)" short form on the first
166
+ # non-empty line of the captured block.
167
+ for line in block.splitlines():
168
+ stripped = line.strip()
169
+ if not stripped:
170
+ continue
171
+ if _SMOKE_NONE_RE.match(stripped):
172
+ return []
173
+ break
174
+
175
+ bullets: list[str] = []
176
+ for line in block.splitlines():
177
+ m = _SMOKE_BULLET_RE.match(line)
178
+ if m:
179
+ item = m.group(1).strip()
180
+ if item:
181
+ bullets.append(item)
182
+ # If the block existed but had no bullets and wasn't the "none" form
183
+ # either, treat as "no smoke tests provided" (None) — the worker
184
+ # didn't actually fill in the template.
185
+ if not bullets:
186
+ return None
187
+ return bullets
188
+
189
+
190
+ def parse_smoke_tests_from_log(
191
+ log_path: str | Path, tail_bytes: int = 65_536,
192
+ ) -> list[str] | None:
193
+ """#252: read the tail of *log_path* and extract any SMOKE_TESTS block.
194
+
195
+ Handles both stream-json logs (decodes assistant text events first)
196
+ and legacy plain-text logs. Returns the same three-state result as
197
+ :func:`_extract_smoke_tests_from_text`.
198
+ """
199
+ p = Path(log_path)
200
+ if not p.exists():
201
+ return None
202
+
203
+ from coord.worker_events import is_stream_json # noqa: PLC0415
204
+
205
+ if is_stream_json(p):
206
+ # Collect assistant text from the structured events. Workers may
207
+ # emit the block in a single assistant turn, so concatenating all
208
+ # of them is enough.
209
+ from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
210
+ texts: list[str] = []
211
+ try:
212
+ with open(p, encoding="utf-8", errors="replace") as f:
213
+ for line in f:
214
+ event = parse_event(line.rstrip("\n"))
215
+ if event is None or event.type != "assistant":
216
+ continue
217
+ t = _assistant_text(event)
218
+ if t:
219
+ texts.append(t)
220
+ except OSError:
221
+ return None
222
+ return _extract_smoke_tests_from_text("\n".join(texts))
223
+
224
+ # Plain-text path: read the tail (large enough to catch the block
225
+ # even when followed by many turns of build output).
226
+ try:
227
+ size = p.stat().st_size
228
+ with open(p, encoding="utf-8", errors="replace") as f:
229
+ if size > tail_bytes:
230
+ f.seek(size - tail_bytes)
231
+ f.readline() # skip partial line
232
+ text = f.read()
233
+ except OSError:
234
+ return None
235
+ return _extract_smoke_tests_from_text(text)
236
+
237
+
238
+ def parse_smoke_tests_from_agent(
239
+ host: str,
240
+ assignment_id: str,
241
+ port: int = 7433,
242
+ timeout: float = 15.0,
243
+ ) -> list[str] | None:
244
+ """#252: fetch a worker's log via the agent's ``/logs/<id>`` endpoint
245
+ and extract the SMOKE_TESTS block.
246
+
247
+ Use this instead of :func:`parse_smoke_tests_from_log` when the worker
248
+ ran on a remote agent and the log isn't on the coordinator's local
249
+ filesystem. Mirrors :func:`coord.review.parse_review_from_agent` and
250
+ :func:`coord.plan_parser.parse_plan_from_agent`. Returns the same
251
+ three-state result as :func:`_extract_smoke_tests_from_text`.
252
+ """
253
+ import httpx # noqa: PLC0415
254
+
255
+ url = f"http://{host}:{port}/logs/{assignment_id}"
256
+ try:
257
+ resp = httpx.get(url, timeout=timeout)
258
+ resp.raise_for_status()
259
+ text = resp.text
260
+ except (httpx.HTTPError, httpx.TimeoutException):
261
+ return None
262
+ if not text:
263
+ return None
264
+
265
+ # Detect stream-json the same way is_stream_json() does for files.
266
+ stream_json = False
267
+ for line in text.splitlines():
268
+ stripped = line.strip()
269
+ if not stripped or stripped.startswith("#"):
270
+ continue
271
+ stream_json = stripped.startswith("{")
272
+ break
273
+
274
+ if stream_json:
275
+ from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
276
+ texts: list[str] = []
277
+ for line in text.splitlines():
278
+ event = parse_event(line.rstrip("\n"))
279
+ if event is None or event.type != "assistant":
280
+ continue
281
+ t = _assistant_text(event)
282
+ if t:
283
+ texts.append(t)
284
+ return _extract_smoke_tests_from_text("\n".join(texts))
285
+
286
+ return _extract_smoke_tests_from_text(text)
287
+
288
+
289
+ # #874: workers emit a "### Summary" markdown heading before finishing.
290
+ # The block extends to the next "###" heading or to end-of-text. We
291
+ # pick the LAST occurrence (workers may recap after final fixes).
292
+ #
293
+ # NOTE: use [ \t]* (horizontal whitespace only) in the prefix — NOT \s* —
294
+ # so the blank line after the heading is NOT consumed by the prefix match.
295
+ # With re.DOTALL, \s* would greedily eat the blank \n, shifting the
296
+ # captured group to start at the *next* ### heading's content instead.
297
+ _SUMMARY_BLOCK_RE = re.compile(
298
+ r"###[ \t]+Summary[ \t]*\n(.*?)(?=\n###[ \t]|\Z)",
299
+ re.DOTALL | re.IGNORECASE,
300
+ )
301
+
302
+
303
+ def _extract_completion_summary_from_text(text: str) -> str | None:
304
+ """#874: pull the ### Summary block out of *text*.
305
+
306
+ Returns:
307
+ * ``None`` — no "### Summary" heading found.
308
+ * ``str`` — the prose text immediately under the heading, stripped
309
+ of leading/trailing whitespace. Empty string is folded to None
310
+ (an empty heading is treated as absent).
311
+
312
+ Picks the LAST block in the text in case the worker re-emitted the
313
+ section after further edits.
314
+ """
315
+ matches = list(_SUMMARY_BLOCK_RE.finditer(text))
316
+ if not matches:
317
+ return None
318
+ prose = matches[-1].group(1).strip()
319
+ return prose if prose else None
320
+
321
+
322
+ def parse_completion_summary_from_log(
323
+ log_path: str | Path, tail_bytes: int = 65_536,
324
+ ) -> str | None:
325
+ """#874: read the tail of *log_path* and extract any ### Summary block.
326
+
327
+ Handles both stream-json logs (decodes assistant text events first)
328
+ and legacy plain-text logs. Returns the same two-state result as
329
+ :func:`_extract_completion_summary_from_text`.
330
+ """
331
+ p = Path(log_path)
332
+ if not p.exists():
333
+ return None
334
+
335
+ from coord.worker_events import is_stream_json # noqa: PLC0415
336
+
337
+ if is_stream_json(p):
338
+ from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
339
+ texts: list[str] = []
340
+ try:
341
+ with open(p, encoding="utf-8", errors="replace") as f:
342
+ for line in f:
343
+ event = parse_event(line.rstrip("\n"))
344
+ if event is None or event.type != "assistant":
345
+ continue
346
+ t = _assistant_text(event)
347
+ if t:
348
+ texts.append(t)
349
+ except OSError:
350
+ return None
351
+ return _extract_completion_summary_from_text("\n".join(texts))
352
+
353
+ try:
354
+ size = p.stat().st_size
355
+ with open(p, encoding="utf-8", errors="replace") as f:
356
+ if size > tail_bytes:
357
+ f.seek(size - tail_bytes)
358
+ f.readline() # skip partial line
359
+ text = f.read()
360
+ except OSError:
361
+ return None
362
+ return _extract_completion_summary_from_text(text)
363
+
364
+
365
+ def parse_completion_summary_from_agent(
366
+ host: str,
367
+ assignment_id: str,
368
+ port: int = 7433,
369
+ timeout: float = 15.0,
370
+ ) -> str | None:
371
+ """#874: fetch a worker's log via the agent's ``/logs/<id>`` endpoint
372
+ and extract the ### Summary block.
373
+
374
+ Use this instead of :func:`parse_completion_summary_from_log` when the
375
+ worker ran on a remote agent and the log isn't on the coordinator's local
376
+ filesystem. Mirrors :func:`parse_smoke_tests_from_agent`.
377
+ """
378
+ import httpx # noqa: PLC0415
379
+
380
+ url = f"http://{host}:{port}/logs/{assignment_id}"
381
+ try:
382
+ resp = httpx.get(url, timeout=timeout)
383
+ resp.raise_for_status()
384
+ text = resp.text
385
+ except (httpx.HTTPError, httpx.TimeoutException):
386
+ return None
387
+ if not text:
388
+ return None
389
+
390
+ # Detect stream-json the same way is_stream_json() does for files.
391
+ stream_json = False
392
+ for line in text.splitlines():
393
+ stripped = line.strip()
394
+ if not stripped or stripped.startswith("#"):
395
+ continue
396
+ stream_json = stripped.startswith("{")
397
+ break
398
+
399
+ if stream_json:
400
+ from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
401
+ decoded: list[str] = []
402
+ for line in text.splitlines():
403
+ event = parse_event(line.rstrip("\n"))
404
+ if event is None or event.type != "assistant":
405
+ continue
406
+ t = _assistant_text(event)
407
+ if t:
408
+ decoded.append(t)
409
+ return _extract_completion_summary_from_text("\n".join(decoded))
410
+
411
+ return _extract_completion_summary_from_text(text)
412
+
413
+
414
+ def _detect_warnings(progress: WorkerProgress, all_updates: list[str]) -> None:
415
+ if progress.stuck:
416
+ progress.warnings.append("worker is STUCK and waiting for guidance")
417
+
418
+ # Two consecutive low-confidence updates
419
+ confidences = []
420
+ for u in all_updates[-5:]:
421
+ m = CONFIDENCE_RE.search(u)
422
+ if m:
423
+ confidences.append(m.group(1).lower())
424
+ if len(confidences) >= 2 and confidences[-1] == "low" and confidences[-2] == "low":
425
+ progress.warnings.append("confidence dropped to low on consecutive updates")