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/split_work.py ADDED
@@ -0,0 +1,210 @@
1
+ """Smart task splitting for large worker dispatches.
2
+
3
+ When a proposal touches more files than the configured threshold, this module
4
+ analyses the file list and splits the work into parallel/sequential chunks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from coord.config import DispatchConfig
15
+
16
+
17
+ @dataclass
18
+ class WorkChunk:
19
+ """A chunk of work split from a larger plan."""
20
+
21
+ chunk_id: int
22
+ files: list[str]
23
+ briefing_fragment: str
24
+ depends_on: list[int] = field(default_factory=list) # chunk IDs this depends on
25
+ estimated_turns: int = 20
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Helpers
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def _is_test_file(f: str) -> bool:
33
+ """Return True if the file is a test file."""
34
+ p = Path(f)
35
+ return (
36
+ len(p.parts) > 0 and p.parts[0] in ("tests", "test")
37
+ or p.name.startswith("test_")
38
+ or p.name.endswith("_test.py")
39
+ )
40
+
41
+
42
+ def _subsystem(f: str) -> str:
43
+ """Return the top-level directory of a file path, or '' for root-level files."""
44
+ parts = Path(f).parts
45
+ return parts[0] if len(parts) > 1 else ""
46
+
47
+
48
+ def _estimate_turns(files: list[str]) -> int:
49
+ """Rough turn estimate: 15 turns per file, minimum 10."""
50
+ return max(10, len(files) * 15)
51
+
52
+
53
+ def _describe_files(files: list[str]) -> str:
54
+ """Short description of files for a briefing fragment."""
55
+ if not files:
56
+ return ""
57
+ if len(files) <= 3:
58
+ return ", ".join(files)
59
+ return f"{', '.join(files[:3])}, and {len(files) - 3} more"
60
+
61
+
62
+ def _batch(items: list[str], size: int) -> list[list[str]]:
63
+ """Split *items* into batches of at most *size* items."""
64
+ return [items[i : i + size] for i in range(0, len(items), size)]
65
+
66
+
67
+ def _module_stem(f: str) -> str | None:
68
+ """Return the bare module name (no test_ prefix, no .py suffix) for .py files."""
69
+ p = Path(f)
70
+ if p.suffix != ".py":
71
+ return None
72
+ stem = p.stem
73
+ if stem.startswith("test_"):
74
+ return stem[5:]
75
+ if stem.endswith("_test"):
76
+ return stem[:-5]
77
+ return stem
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Core entry point
82
+ # ---------------------------------------------------------------------------
83
+
84
+ def analyze_plan(
85
+ files_likely: list[str],
86
+ dispatch_config: "DispatchConfig",
87
+ ) -> list[WorkChunk]:
88
+ """Analyse files and split into parallel/sequential chunks if needed.
89
+
90
+ Returns a single-chunk list (chunk_id=1) when the total file count is at or
91
+ below ``dispatch_config.max_files_per_worker``.
92
+
93
+ Splitting heuristics
94
+ --------------------
95
+ a. **File count**: more than ``max_files_per_worker`` triggers a split.
96
+ b. **Subsystem grouping**: files are grouped by their top-level directory.
97
+ Each subsystem becomes its own chunk (sub-split again if still too big).
98
+ c. **New module + consumers**: the impl chunk that introduces a new module
99
+ is labelled chunk A; test chunks that are named ``test_{module}`` are
100
+ given ``depends_on=[A.chunk_id]`` so they run after the module exists.
101
+ d. **Mechanical/cleanup chunk**: all test files (``tests/`` dir or
102
+ ``test_*.py``) are batched into a single test chunk that depends on
103
+ every implementation chunk, or on the specific impl chunk it tests
104
+ (when the name matches ``test_{module}`` ↔ ``{module}.py``).
105
+
106
+ Args:
107
+ files_likely: List of file paths the worker will touch.
108
+ dispatch_config: Config section with splitting thresholds.
109
+
110
+ Returns:
111
+ Ordered list of WorkChunk objects. Independent chunks can be
112
+ dispatched in parallel; chunks with ``depends_on`` must wait.
113
+ """
114
+ if not files_likely or len(files_likely) <= dispatch_config.max_files_per_worker:
115
+ return [
116
+ WorkChunk(
117
+ chunk_id=1,
118
+ files=list(files_likely),
119
+ briefing_fragment=_describe_files(files_likely) if files_likely else "",
120
+ depends_on=[],
121
+ estimated_turns=_estimate_turns(files_likely),
122
+ )
123
+ ]
124
+
125
+ # ── Separate tests from implementation files ──────────────────────────
126
+ test_files = [f for f in files_likely if _is_test_file(f)]
127
+ impl_files = [f for f in files_likely if not _is_test_file(f)]
128
+
129
+ # ── Group impl files by top-level directory (subsystem) ───────────────
130
+ groups: dict[str, list[str]] = {}
131
+ for f in impl_files:
132
+ key = _subsystem(f)
133
+ groups.setdefault(key, []).append(f)
134
+
135
+ # ── Create one chunk per subsystem (sub-split if still oversized) ─────
136
+ chunks: list[WorkChunk] = []
137
+ chunk_id = 1
138
+
139
+ for subsystem in sorted(groups.keys()):
140
+ sub_files = groups[subsystem]
141
+ label = subsystem if subsystem else "root"
142
+ for batch in _batch(sub_files, dispatch_config.max_files_per_worker):
143
+ frag = f"{label}: {_describe_files(batch)}"
144
+ chunks.append(
145
+ WorkChunk(
146
+ chunk_id=chunk_id,
147
+ files=batch,
148
+ briefing_fragment=frag,
149
+ depends_on=[],
150
+ estimated_turns=_estimate_turns(batch),
151
+ )
152
+ )
153
+ chunk_id += 1
154
+
155
+ # ── Build module-name → chunk-id map for dependency wiring ───────────
156
+ # Maps bare stem (e.g. "split_work") → chunk_id of the impl chunk that
157
+ # introduces it. We prefer the *first* chunk that owns the file.
158
+ module_to_chunk: dict[str, int] = {}
159
+ for chunk in chunks:
160
+ for f in chunk.files:
161
+ stem = _module_stem(f)
162
+ if stem and stem not in module_to_chunk:
163
+ module_to_chunk[stem] = chunk.chunk_id
164
+
165
+ # ── Add test chunk (if any test files exist) ──────────────────────────
166
+ if test_files:
167
+ test_deps: set[int] = set()
168
+ has_unmatched = False
169
+ for tf in test_files:
170
+ stem = _module_stem(tf)
171
+ if stem and stem in module_to_chunk:
172
+ # This test is specifically for a new module in chunk N
173
+ test_deps.add(module_to_chunk[stem])
174
+ else:
175
+ has_unmatched = True
176
+
177
+ if not test_deps or has_unmatched:
178
+ # Some test files have no name-based match → conservatively depend
179
+ # on ALL implementation chunks to guarantee ordering correctness.
180
+ test_deps = {c.chunk_id for c in chunks}
181
+
182
+ test_label = _subsystem(test_files[0]) or "tests"
183
+ frag = f"{test_label}: {_describe_files(test_files)}"
184
+ chunks.append(
185
+ WorkChunk(
186
+ chunk_id=chunk_id,
187
+ files=test_files,
188
+ briefing_fragment=frag,
189
+ depends_on=sorted(test_deps),
190
+ estimated_turns=_estimate_turns(test_files),
191
+ )
192
+ )
193
+
194
+ return chunks
195
+
196
+
197
+ def format_chunks_summary(chunks: list[WorkChunk]) -> str:
198
+ """Return a human-readable summary of the split chunks."""
199
+ if len(chunks) <= 1:
200
+ return ""
201
+ lines: list[str] = [f" Split into {len(chunks)} chunks:"]
202
+ for chunk in chunks:
203
+ dep_str = ""
204
+ if chunk.depends_on:
205
+ dep_str = f" (after chunk {', '.join(str(d) for d in chunk.depends_on)})"
206
+ lines.append(
207
+ f" Chunk {chunk.chunk_id}: {len(chunk.files)} files — "
208
+ f"{chunk.briefing_fragment}{dep_str}"
209
+ )
210
+ return "\n".join(lines)