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/plan_parser.py ADDED
@@ -0,0 +1,266 @@
1
+ """Parse structured plan output from plan-only worker logs.
2
+
3
+ Plan workers (``type="plan"`` assignments) are instructed to output a fixed
4
+ set of section headings. This module extracts those headings from either a
5
+ plain-text log or a stream-json log and returns a :class:`WorkerPlan`
6
+ dataclass.
7
+
8
+ Recognised headings (as defined in ``WORKER_PLAN_PROMPT`` in ``coord.agent``):
9
+
10
+ PLAN: (optional) short one-line summary of the overall plan
11
+ FILES_READ: comma-separated list of every file the worker examined
12
+ FILES_MODIFY: comma-separated list of files that would need to change
13
+ APPROACH: concise implementation approach (multi-sentence)
14
+ RISKS: potential blockers, conflicts, or tricky areas
15
+ ESTIMATE: rough complexity: trivial | small | medium | large
16
+
17
+ Plus a fenced ``SMOKE_TESTS:`` / ``END_SMOKE_TESTS`` block — bullets the
18
+ human should run after the work lands. Parsed the same way the work
19
+ worker's end-of-job block is (``coord.progress.parse_smoke_tests``) so
20
+ the two sources are interchangeable.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+
30
+ # All section keywords the parser recognises. Order matters for display but
31
+ # not for parsing.
32
+ SECTION_KEYWORDS = ["PLAN", "FILES_READ", "FILES_MODIFY", "APPROACH", "RISKS", "ESTIMATE"]
33
+
34
+ # Match a section header at the start of a line, capturing the keyword.
35
+ # Tolerates optional leading markdown (e.g. "### FILES_READ:" or "**FILES_READ:**").
36
+ # The trailing optional \*{1,2} handles bold-close after the colon: **KEYWORD:**
37
+ _SECTION_RE = re.compile(
38
+ r"^(?:[#*]+\s+|\*{1,2})?(" + "|".join(SECTION_KEYWORDS) + r"):(?:\*{1,2})?\s*",
39
+ re.MULTILINE,
40
+ )
41
+
42
+
43
+ @dataclass
44
+ class WorkerPlan:
45
+ """Structured plan extracted from a plan-only worker log."""
46
+
47
+ plan: str = ""
48
+ files_read: list[str] = field(default_factory=list)
49
+ files_modify: list[str] = field(default_factory=list)
50
+ approach: str = ""
51
+ risks: str = ""
52
+ estimate: str = ""
53
+ # SMOKE_TESTS block emitted by the plan worker. Tri-state, same shape as
54
+ # Assignment.smoke_tests / parse_smoke_tests_from_log:
55
+ # None — no block emitted (or block was empty/malformed)
56
+ # [] — explicit "(none — change is internal)"
57
+ # list — one bullet per item
58
+ smoke_tests: list[str] | None = None
59
+ raw_text: str = ""
60
+
61
+ def is_empty(self) -> bool:
62
+ """True iff no structured sections were found."""
63
+ return not any(
64
+ [
65
+ self.plan,
66
+ self.files_read,
67
+ self.files_modify,
68
+ self.approach,
69
+ self.risks,
70
+ self.estimate,
71
+ self.smoke_tests is not None,
72
+ ]
73
+ )
74
+
75
+ def to_dict(self) -> dict:
76
+ return {
77
+ "plan": self.plan,
78
+ "files_read": self.files_read,
79
+ "files_modify": self.files_modify,
80
+ "approach": self.approach,
81
+ "risks": self.risks,
82
+ "estimate": self.estimate,
83
+ "smoke_tests": self.smoke_tests,
84
+ "raw_text": self.raw_text,
85
+ }
86
+
87
+ @classmethod
88
+ def from_dict(cls, data: dict) -> WorkerPlan:
89
+ return cls(
90
+ plan=data.get("plan", ""),
91
+ files_read=data.get("files_read", []),
92
+ files_modify=data.get("files_modify", []),
93
+ approach=data.get("approach", ""),
94
+ risks=data.get("risks", ""),
95
+ estimate=data.get("estimate", ""),
96
+ smoke_tests=data.get("smoke_tests"),
97
+ raw_text=data.get("raw_text", ""),
98
+ )
99
+
100
+
101
+ # ── Text extraction ──────────────────────────────────────────────────────────
102
+
103
+
104
+ def _extract_text_from_stream_json(log_path: Path) -> str:
105
+ """Concatenate all assistant text blocks from a stream-json log.
106
+
107
+ #1710 inventory: kept as a direct ``coord.worker_events`` import rather
108
+ than routed through ``provider.parse_log()`` — this decodes the generic
109
+ Anthropic-Messages-API ``type: "assistant"`` / ``message.content``
110
+ envelope (a wire-format detail, not claude business semantics), and
111
+ ``WorkerSummary`` has no "raw assistant text" field to route through
112
+ without adding a new abstract ``Provider`` method (which would force a
113
+ change in every concrete provider, including the out-of-scope
114
+ ``opencode.py``). Same reasoning as ``coord.review``'s equivalent
115
+ helpers; see ``tests/test_provider_seam.py``.
116
+ """
117
+ from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
118
+
119
+ parts: list[str] = []
120
+ try:
121
+ with open(log_path, encoding="utf-8", errors="replace") as f:
122
+ for line in f:
123
+ event = parse_event(line.rstrip("\n"))
124
+ if event is None:
125
+ continue
126
+ if event.type == "assistant":
127
+ text = _assistant_text(event)
128
+ if text:
129
+ parts.append(text)
130
+ except OSError:
131
+ pass
132
+ return "\n".join(parts)
133
+
134
+
135
+ # ── Section parsing ──────────────────────────────────────────────────────────
136
+
137
+
138
+ def _parse_sections(text: str) -> dict[str, str]:
139
+ """Split *text* on section headers and return a keyword → content dict."""
140
+ if not text:
141
+ return {}
142
+ matches = list(_SECTION_RE.finditer(text))
143
+ if not matches:
144
+ return {}
145
+ result: dict[str, str] = {}
146
+ for i, m in enumerate(matches):
147
+ keyword = m.group(1)
148
+ content_start = m.end()
149
+ content_end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
150
+ result[keyword] = text[content_start:content_end].strip()
151
+ return result
152
+
153
+
154
+ def _parse_file_list(value: str) -> list[str]:
155
+ """Split a comma-separated file list, stripping blank entries."""
156
+ if not value:
157
+ return []
158
+ return [f.strip() for f in value.split(",") if f.strip()]
159
+
160
+
161
+ # ── Public API ───────────────────────────────────────────────────────────────
162
+
163
+
164
+ def parse_plan_text(text: str) -> WorkerPlan:
165
+ """Parse structured plan sections from raw text.
166
+
167
+ Returns a :class:`WorkerPlan` whose ``raw_text`` is always set to *text*,
168
+ and whose section fields are populated from any recognised headings found.
169
+ """
170
+ from coord.progress import _extract_smoke_tests_from_text # noqa: PLC0415
171
+
172
+ sections = _parse_sections(text)
173
+ return WorkerPlan(
174
+ plan=sections.get("PLAN", ""),
175
+ files_read=_parse_file_list(sections.get("FILES_READ", "")),
176
+ files_modify=_parse_file_list(sections.get("FILES_MODIFY", "")),
177
+ approach=sections.get("APPROACH", ""),
178
+ risks=sections.get("RISKS", ""),
179
+ estimate=sections.get("ESTIMATE", ""),
180
+ smoke_tests=_extract_smoke_tests_from_text(text),
181
+ raw_text=text,
182
+ )
183
+
184
+
185
+ def parse_plan_from_log(log_path: str | Path) -> WorkerPlan | None:
186
+ """Parse a :class:`WorkerPlan` from a worker log file.
187
+
188
+ Handles both stream-json (``--output-format stream-json``) and plain-text
189
+ log formats. Returns ``None`` if the file does not exist or contains no
190
+ recognised plan sections.
191
+
192
+ #1710 inventory: ``is_stream_json`` is a generic on-disk-shape sniff, not
193
+ claude-specific parsing — kept direct. See the note on
194
+ ``_extract_text_from_stream_json`` above.
195
+ """
196
+ from coord.worker_events import is_stream_json # noqa: PLC0415
197
+
198
+ p = Path(log_path)
199
+ if not p.exists():
200
+ return None
201
+
202
+ if is_stream_json(p):
203
+ text = _extract_text_from_stream_json(p)
204
+ else:
205
+ try:
206
+ text = p.read_text(encoding="utf-8", errors="replace")
207
+ except OSError:
208
+ return None
209
+
210
+ plan = parse_plan_text(text)
211
+ return None if plan.is_empty() else plan
212
+
213
+
214
+ def parse_plan_from_agent(
215
+ host: str,
216
+ assignment_id: str,
217
+ port: int = 7433,
218
+ timeout: float = 15.0,
219
+ ) -> WorkerPlan | None:
220
+ """Fetch a plan worker's log via the agent's ``/logs/<id>`` endpoint
221
+ and parse it.
222
+
223
+ Use this instead of :func:`parse_plan_from_log` when the plan worker
224
+ ran on a remote agent and the log file isn't on the coordinator's
225
+ local filesystem. Mirrors :func:`coord.review.parse_review_from_agent`.
226
+ Returns ``None`` on network failure, empty log, or no recognised plan
227
+ sections.
228
+ """
229
+ import httpx # noqa: PLC0415
230
+
231
+ url = f"http://{host}:{port}/logs/{assignment_id}"
232
+ try:
233
+ resp = httpx.get(url, timeout=timeout)
234
+ resp.raise_for_status()
235
+ text = resp.text
236
+ except (httpx.HTTPError, httpx.TimeoutException):
237
+ return None
238
+ if not text:
239
+ return None
240
+
241
+ # The agent returns stream-json verbatim when the worker was launched
242
+ # with --output-format stream-json (the standard). Detect the same way
243
+ # is_stream_json() does for files: first non-comment, non-blank line
244
+ # starts with `{`.
245
+ stream_json = False
246
+ for line in text.splitlines():
247
+ stripped = line.strip()
248
+ if not stripped or stripped.startswith("#"):
249
+ continue
250
+ stream_json = stripped.startswith("{")
251
+ break
252
+
253
+ if stream_json:
254
+ # Reuse the stream-json text extractor by writing to a temp file —
255
+ # the existing extractor opens by path. Cheap enough at log sizes
256
+ # we see in practice (≤ a few MB).
257
+ import tempfile # noqa: PLC0415
258
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=True) as tf:
259
+ tf.write(text)
260
+ tf.flush()
261
+ extracted = _extract_text_from_stream_json(Path(tf.name))
262
+ else:
263
+ extracted = text
264
+
265
+ plan = parse_plan_text(extracted)
266
+ return None if plan.is_empty() else plan