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/models.py ADDED
@@ -0,0 +1,891 @@
1
+ """Dataclasses for the coordinator: repos, machines, assignments, board."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, time, timedelta, timezone
8
+ from pathlib import Path
9
+ from zoneinfo import ZoneInfo
10
+
11
+ # #316: pattern that distinguishes a file-path value for `new_issue_guidance`
12
+ # from inline markdown text. Matches paths like `docs/ISSUE_GUIDANCE.md` or
13
+ # `GUIDANCE.txt` but not multi-line or space-containing strings.
14
+ #
15
+ # The negative lookaheads reject (a) traversal sequences (`../`) and (b) any
16
+ # value starting with `/` or `\` (an absolute path). Both protections matter
17
+ # because `Path("/repo") / "/etc/passwd.md"` silently discards the base and
18
+ # returns `Path("/etc/passwd.md")`, so absolute paths would otherwise escape
19
+ # the repo root just as effectively as `../`. `resolve_new_issue_guidance`
20
+ # adds a second belt-and-braces check via `Path.resolve()` containment.
21
+ _GUIDANCE_PATH_RE: re.Pattern[str] = re.compile(
22
+ r"^(?![/\\])(?!.*\.\.[/\\])[\w./\-]+\.(md|txt)$", re.IGNORECASE
23
+ )
24
+
25
+
26
+ @dataclass
27
+ class WorkerPermissionsConfig:
28
+ """Per-repo allow/deny lists for worker commands.
29
+
30
+ When ``deny`` is non-empty the coordinator injects a "forbidden commands"
31
+ section into the worker system prompt so that ``claude -p`` refuses to run
32
+ the listed patterns. An empty ``deny`` list (``deny: []``) means no
33
+ restrictions.
34
+ """
35
+
36
+ allow: list[str] = field(default_factory=list)
37
+ deny: list[str] = field(default_factory=list)
38
+
39
+
40
+ @dataclass
41
+ class Repo:
42
+ name: str
43
+ github: str
44
+ depends_on: list[str] = field(default_factory=list)
45
+ default_branch: str = "main"
46
+ # #934 (Pipeline v2 Phase 4, docs/PIPELINE_V2.md "Git model"): when set,
47
+ # this repo has opted into the develop + feature-branch-per-milestone
48
+ # git model — `develop` is the integration branch (`default_branch`
49
+ # becomes the release branch) and milestone issues branch off
50
+ # `feature/ms-NN` (see coord/branch_model.py) instead of
51
+ # `default_branch` directly. `None` (the default) means the repo is
52
+ # unaffected: every branch-resolution call site falls back to today's
53
+ # flat `default_branch` behavior. This is the opt-in guard that keeps
54
+ # in-flight work on `main` from breaking when `develop` appears for
55
+ # repos that adopt the new model.
56
+ develop_branch: str | None = None
57
+ build_command: str | None = None
58
+ test_command: str | None = None
59
+ # #296: optional shell command to interactively run the app for manual
60
+ # smoke testing. Surfaced in the TUI Test stage detail panel so the
61
+ # tester knows exactly what to launch.
62
+ run_cmd: str | None = None
63
+ worker_permissions: WorkerPermissionsConfig | None = None
64
+ housekeeping: list[str] = field(default_factory=list)
65
+ coordinator_only_files: list[str] = field(default_factory=list)
66
+ # #268: repos a worker may reference for context but doesn't actually
67
+ # build against. Common cases: sister projects extracted from a
68
+ # common ancestor (quadraui ← vimcode), reference implementations,
69
+ # "lift X out of Y into Z" issues.
70
+ #
71
+ # Honoured by the freshness check (pulled alongside `depends_on`)
72
+ # but ignored by the cycle detector — so a repo can list a sibling
73
+ # that already points back via `depends_on` without tripping the
74
+ # validator. Reference entries do NOT walk transitively — they're
75
+ # a flat list.
76
+ reference_repos: list[str] = field(default_factory=list)
77
+ # #316: per-repo guidance for drafting new GitHub issues. Accepts either
78
+ # an inline markdown string OR a file path relative to the repo root
79
+ # (e.g. `docs/ISSUE_GUIDANCE.md`). See `resolve_new_issue_guidance`.
80
+ new_issue_guidance: str | None = None
81
+ # #305: glob patterns (relative to the worktree root) for build artifacts
82
+ # to stash before the worktree is removed. Matches are copied to
83
+ # ~/.coord/artifacts/<repo>/<branch>/ on the agent with latest-wins
84
+ # semantics per (repo, branch) pair. Files under 100 bytes or ending
85
+ # in `.d` are excluded (dependency files, not binaries).
86
+ artifact_paths: list[str] = field(default_factory=list)
87
+ # #323: optional provider override for workers dispatched to this repo.
88
+ # When set, overrides providers.default from coordinator.yml. The value
89
+ # must match a key in providers.definitions (or be "claude" which is
90
+ # always implicit). None means "use the global default".
91
+ provider: str | None = None
92
+
93
+ def resolve_new_issue_guidance(self, repo_path: Path) -> str:
94
+ """Return the new-issue guidance string for this repo.
95
+
96
+ Resolution order:
97
+ 1. If ``new_issue_guidance`` is ``None`` (or empty), return a
98
+ generic default describing the required issue sections.
99
+ 2. If the value matches ``[\\w/.-]+\\.(md|txt)$`` **and** the file
100
+ exists at ``repo_path / value``, return the file contents.
101
+ 3. If the pattern matches but the file is missing, return the value
102
+ verbatim as inline text (so a misconfigured path is still visible
103
+ to the worker rather than silently replaced).
104
+ 4. Otherwise, return the value verbatim (it is inline markdown).
105
+ """
106
+ _DEFAULT = (
107
+ "Required sections: "
108
+ "Title (active voice, ≤80 chars), "
109
+ "What (1-3 sentences), "
110
+ "Acceptance (bulleted, observable), "
111
+ "Out of scope"
112
+ )
113
+ if not self.new_issue_guidance or not self.new_issue_guidance.strip():
114
+ return _DEFAULT
115
+ value = self.new_issue_guidance.strip()
116
+ if _GUIDANCE_PATH_RE.match(value):
117
+ # Belt-and-braces against an escape from `repo_path`: resolve the
118
+ # candidate and the base, then confirm the candidate stays under
119
+ # the base. This guards against any future regex regression as
120
+ # well as edge cases like symlinks pointing outside the tree.
121
+ try:
122
+ base = repo_path.resolve()
123
+ candidate = (repo_path / value).resolve()
124
+ except (OSError, RuntimeError):
125
+ # Resolution failure (e.g. permission denied, symlink loop) —
126
+ # treat as inline so we never silently read a surprising file.
127
+ return value
128
+ try:
129
+ candidate.relative_to(base)
130
+ except ValueError:
131
+ # Path escapes the repo root — treat as inline rather than
132
+ # reading a file outside the trusted tree.
133
+ return value
134
+ try:
135
+ return candidate.read_text(encoding="utf-8", errors="replace")
136
+ except (OSError, FileNotFoundError):
137
+ # File missing — fall back to inline so the value is at least
138
+ # surfaced in the prompt rather than silently defaulting.
139
+ return value
140
+ return value
141
+
142
+
143
+ @dataclass
144
+ class QuietHours:
145
+ """A machine's recurring daily no-new-dispatch window (#1862).
146
+
147
+ ``start``/``end`` are wall-clock ``datetime.time`` values evaluated in
148
+ ``tz`` — a REQUIRED IANA zone name (see ``coord.config._parse_machines``,
149
+ which refuses to parse a ``quiet_hours`` block with a missing/invalid
150
+ ``tz`` rather than silently defaulting). `coord serve` runs on UTC; a
151
+ naive ``"23:00"`` compared against the daemon's own clock would fire at
152
+ the wrong wall-clock hour for any non-UTC operator — exactly the bug
153
+ this field exists to prevent.
154
+
155
+ This reuses ``coord.machine_pause``'s existing routing-pause semantics:
156
+ it governs the ROUTING decision for *new* dispatch only and never
157
+ cancels an in-flight assignment — a task still running when the window
158
+ opens finishes normally.
159
+
160
+ ``start == end`` is rejected at config-parse time (ambiguous — "quiet
161
+ all day" vs "never quiet" — rather than guessed; a machine that wants to
162
+ be quiet all day should just be `coord pause`d).
163
+
164
+ Known limitation (#1862 review, not blocking, no issue filed yet):
165
+ ``covers()``/``window_end_instant()`` do plain wall-clock arithmetic via
166
+ ``ZoneInfo`` with no special-casing for the two annual DST-transition
167
+ days in ``tz``. A window boundary that falls in a nonexistent (spring-
168
+ forward) or ambiguous (fall-back) local hour on those two days could be
169
+ off by up to an hour. Not called out by #1862 and not a regression from
170
+ anything that existed before it — a fix would need to decide which of
171
+ the two ambiguous instants "wins" and is a candidate for its own
172
+ follow-up rather than folding into this feature.
173
+ """
174
+
175
+ start: time
176
+ end: time
177
+ tz: str
178
+
179
+ def covers(self, now: datetime | None = None) -> bool:
180
+ """True when *now* falls inside this window, evaluated in ``tz``.
181
+
182
+ Half-open interval ``[start, end)``: the instant ``start`` is
183
+ covered, the instant ``end`` is not — the machine wakes up exactly
184
+ at ``end``, not a minute after. ``start > end`` denotes a window
185
+ that wraps midnight (e.g. ``23:00`` → ``08:00``).
186
+ """
187
+ moment = self._local_time(now)
188
+ if self.start <= self.end:
189
+ return self.start <= moment < self.end
190
+ return moment >= self.start or moment < self.end
191
+
192
+ def window_end_instant(self, now: datetime | None = None) -> datetime:
193
+ """The absolute UTC instant the window containing *now* ends.
194
+
195
+ Only meaningful when ``self.covers(now)`` is true — used by
196
+ ``coord.machine_pause`` to size a ``coord unpause`` override so it
197
+ expires exactly when the window would have anyway, rather than a
198
+ fixed duration that could outlive or undershoot it.
199
+ """
200
+ now = self._aware(now)
201
+ zone = ZoneInfo(self.tz)
202
+ local_now = now.astimezone(zone)
203
+ end_dt = local_now.replace(
204
+ hour=self.end.hour, minute=self.end.minute, second=0, microsecond=0,
205
+ )
206
+ if self.start > self.end and local_now.time() >= self.start:
207
+ # Wrapping window, currently in the "evening" half — the window
208
+ # ends tomorrow's clock time, not today's.
209
+ end_dt = end_dt + timedelta(days=1)
210
+ return end_dt.astimezone(timezone.utc)
211
+
212
+ def _local_time(self, now: datetime | None) -> time:
213
+ return self._aware(now).astimezone(ZoneInfo(self.tz)).time()
214
+
215
+ @staticmethod
216
+ def _aware(now: datetime | None) -> datetime:
217
+ now = now if now is not None else datetime.now(timezone.utc)
218
+ return now if now.tzinfo is not None else now.replace(tzinfo=timezone.utc)
219
+
220
+
221
+ @dataclass
222
+ class Machine:
223
+ name: str
224
+ host: str
225
+ capabilities: list[str] = field(default_factory=list)
226
+ repos: list[str] = field(default_factory=list)
227
+ repo_paths: dict[str, str] = field(default_factory=dict)
228
+ # #1417: optional per-machine override of `concurrency.max_workers`.
229
+ # `None` means "no override" — the machine's effective cap is the
230
+ # fleet-wide default. Set this lower on hardware that can't keep up with
231
+ # the fleet norm (e.g. a 4-core box among 20-core desktops) so automated
232
+ # capacity checks (`coord retry`) don't pile concurrent workers onto it.
233
+ max_workers: int | None = None
234
+ # #1862: optional recurring daily no-new-dispatch window. `None` (unset,
235
+ # the default) means "behaves exactly as before this feature" — see
236
+ # `QuietHours` above. Routing consults this only through
237
+ # `coord.machine_pause.paused_set()`, never directly.
238
+ quiet_hours: QuietHours | None = None
239
+
240
+ def can_work_on(self, repo_name: str) -> bool:
241
+ return repo_name in self.repos
242
+
243
+ def repo_path(self, repo_name: str) -> str | None:
244
+ return self.repo_paths.get(repo_name)
245
+
246
+
247
+ # #930 review fix: assignment ``type`` values that should flow through the
248
+ # normal Work → Test → Review → Merge pipeline like any other "work" — i.e.
249
+ # their completion is eligible for `coord.review.dispatch_review` /
250
+ # `dispatch_pending_reviews` and for the merge-queue auto-enqueue
251
+ # (`coord.merge_queue.enqueue_approved_work`, `coord.commands.merge`'s
252
+ # auto-enqueue scan). "work" is the default worker type; "mock-author"
253
+ # (#930, Gate A) commits `tests/acceptance/ms-NN/contract.md` and its own
254
+ # docstring/system-prompt promise it dispatches through the *same* pipeline
255
+ # as any other branch — so every completion-side filter that only matched
256
+ # ``type == "work"`` must also match this set, or a mock-author branch can
257
+ # never actually reach a review or the merge queue. "test-author" (#931,
258
+ # #1141) is structurally identical — it commits only
259
+ # `tests/acceptance/ms-NN/**` and needs a `skipped` test verdict — but was
260
+ # never added when mock-author landed, so every per-issue JIT acceptance
261
+ # slice silently stalled before review/merge with no error (confirmed live,
262
+ # #1141). Keep this set — not a bare string check — as the single source of
263
+ # truth so a future work-like type only needs to be added here.
264
+ WORK_LIKE_TYPES: frozenset[str] = frozenset({"work", "mock-author", "test-author"})
265
+
266
+ # #1175: subset of WORK_LIKE_TYPES whose entire job is writing under a
267
+ # repo's sealed acceptance paths (docs/ORACLE_LOOP.md — today just
268
+ # `tests/acceptance/`). `coord.review`'s oracle-tamper rule normally treats
269
+ # ANY diff touching a sealed path as a mandatory `request-changes` — correct
270
+ # for a `type="work"` PR (a worker must never edit the suite it's graded
271
+ # against) but a guaranteed false positive for these types, since authoring
272
+ # `tests/acceptance/ms-NN/**` (contract.md + mocks for "mock-author", the
273
+ # acceptance slice itself for "test-author") *is* the assignment. For these
274
+ # types the rule inverts: the violation is touching anything OUTSIDE the
275
+ # sealed path, not touching it. Keep this as a set here (not a bare string
276
+ # check in coord/review.py) so a future sealed-path-authoring type only
277
+ # needs to be added in one place.
278
+ SEALED_PATH_AUTHOR_TYPES: frozenset[str] = frozenset({"test-author", "mock-author"})
279
+
280
+ # #1077: subset of WORK_LIKE_TYPES whose ``issue_number`` is the issue the PR
281
+ # actually *resolves* — i.e. merging it should auto-close that issue. "work"
282
+ # qualifies. "mock-author" (Gate A) is WORK_LIKE (it flows through the same
283
+ # Work → Test → Review → Merge pipeline) but its ``issue_number`` is the
284
+ # *milestone's tracking/epic issue* (see ``coord/mock_author.py``), not
285
+ # something the contract PR resolves — merging it must NOT close that issue,
286
+ # or the epic reads as "done" while its real sub-issues are still open/
287
+ # untouched (claude-coordinator#1041 got closed this way). This is the single
288
+ # source of truth for both the PR-body "Closes #N" vs "Refs #N" keyword
289
+ # choice (``coord/review.py``, ``coord/merge_queue.py``'s ``_briefing_body``)
290
+ # and the deterministic post-merge ``close_issue`` call
291
+ # (``coord/merge_queue.py``'s ``process``). "test-author" (#1141) deliberately
292
+ # stays OUT of this set too — its ``issue_number`` is likewise always the
293
+ # milestone's tracking issue (see ``for_issue_number`` below for the actual
294
+ # per-slice issue), never something the contract/fixture PR resolves.
295
+ # Verified against confirmed-live behaviour (PR #1139 correctly used
296
+ # "Refs #1117"), not merely assumed.
297
+ CLOSES_ISSUE_TYPES: frozenset[str] = frozenset({"work"})
298
+
299
+ # #1142: the assignment `type` `coord pr` gives its PR-opening helper session
300
+ # when the *original* assignment it's opening a PR for is NOT itself in
301
+ # ``CLOSES_ISSUE_TYPES`` (e.g. "test-author"/"mock-author", whose
302
+ # ``issue_number`` is the milestone's tracking issue, not something this PR
303
+ # resolves — see ``CLOSES_ISSUE_TYPES`` above). Before #1142, `coord pr`
304
+ # unconditionally dispatched its helper with the default `type="work"`,
305
+ # which made ``coord.stage_projection.merge_stage_status_for``'s #775
306
+ # fallback (and ``issue_has_any_approved_review``) mistake a merged
307
+ # PR-helper for the *tracking issue's own* merged work, showing an open epic
308
+ # as "Done". Giving the helper this distinct type keeps it out of both
309
+ # ``WORK_LIKE_TYPES`` and ``CLOSES_ISSUE_TYPES`` (and therefore out of every
310
+ # heuristic keyed on either) while still being dispatched exactly like a
311
+ # normal "work" session otherwise — see ``coord.agent.WRITE_CAPABLE_SPEC_TYPES``,
312
+ # which must also list it since it mutates GitHub via `gh pr create`.
313
+ PR_HELPER_TYPE = "pr-helper"
314
+
315
+ # #685: the per-issue Test-stage POLICY labels, and the one pure function that
316
+ # reads them. ``test-mode:auto`` → the headless Test stage auto-dispatches
317
+ # (`coord.smoke.dispatch_pending_smoke`); ``test-mode:smoke`` → it deliberately
318
+ # does NOT, because the Test stage for that issue is human-attended (the TUI
319
+ # offers an interactive smoke agent instead); no label → back-compat, respect
320
+ # ``smoke_tests.auto_queue``.
321
+ #
322
+ # #2024: this used to be an inline pair of `in labels` checks inside
323
+ # ``coord.state._get_issue_test_mode_local`` and nowhere else, because only the
324
+ # dispatcher ever asked. The DRIVER has to ask too — a `test-mode:smoke` issue
325
+ # is precisely the shape where a completed fix round's Test stage will never be
326
+ # dispatched by anything automatic (``coord.dead_end`` shape 3), and a driver
327
+ # that can't see the policy counts "no state change" against a transition that
328
+ # is never coming (vimcode#635: 25 min, then 160 min, on one issue). Hoisted
329
+ # here — import-light, no DB — so the dispatcher's reading and the driver's
330
+ # reading are the same three lines and cannot drift.
331
+ TEST_MODE_AUTO_LABEL = "test-mode:auto"
332
+ TEST_MODE_SMOKE_LABEL = "test-mode:smoke"
333
+
334
+
335
+ def test_mode_from_labels(labels) -> str | None:
336
+ """``"auto"`` / ``"smoke"`` / ``None`` for an issue's GitHub *labels*.
337
+
338
+ ``auto`` wins when both are present (an explicit opt-in to the headless
339
+ path beats the human-attended default), matching the original
340
+ ``_get_issue_test_mode_local`` ordering exactly. Tolerates ``None`` and
341
+ any non-list input by returning ``None`` — every caller here fails OPEN
342
+ onto "no policy set", never onto a policy nobody asked for.
343
+ """
344
+ if not labels:
345
+ return None
346
+ try:
347
+ names = list(labels)
348
+ except TypeError:
349
+ return None
350
+ if TEST_MODE_AUTO_LABEL in names:
351
+ return "auto"
352
+ if TEST_MODE_SMOKE_LABEL in names:
353
+ return "smoke"
354
+ return None
355
+
356
+
357
+ @dataclass
358
+ class Assignment:
359
+ machine_name: str
360
+ repo_name: str
361
+ issue_number: int
362
+ issue_title: str
363
+ files_allowed: list[str] = field(default_factory=list)
364
+ files_forbidden: list[str] = field(default_factory=list)
365
+ briefing: str = ""
366
+ assignment_id: str | None = None
367
+ status: str = "pending" # pending | running | done | failed | advisory
368
+ branch: str | None = None
369
+ pr_url: str | None = None
370
+ dispatched_at: float | None = None
371
+ finished_at: float | None = None
372
+ smoke_test: str | None = None # None | pass | fail
373
+ smoke_test_reason: str | None = None
374
+ # "work" (default), "review", "plan", "smoke", "conflict-fix", "audit",
375
+ # or a handful of other human-attended flavours (troubleshoot, chat,
376
+ # merge). Review assignments target an existing PR rather than
377
+ # implementing a fresh issue. Plan assignments are read-only: the worker
378
+ # analyses the codebase and outputs a structured plan without writing any
379
+ # code. conflict-fix is dispatched when a merge fails with a mechanical
380
+ # (non-semantic) conflict — the worker rebases, resolves obvious
381
+ # additive merges, and force-pushes; the coordinator owns the retry.
382
+ # audit (#885) is a read-only, human-attended milestone-outcome analyst —
383
+ # dispatched via `--audit-of <epic_issue>`, never through the headless
384
+ # Work → Test → Review → Merge pipeline.
385
+ type: str = "work"
386
+ review_target: str | None = None
387
+ review_of_assignment_id: str | None = None
388
+ unreachable_count: int = 0
389
+ # Model tier the worker was dispatched with (e.g. "haiku", "sonnet",
390
+ # "opus"). None means the worker used claude's default. Tracked on the
391
+ # board so escalation in `coord fix` / `coord retry` / `coord resume-stuck`
392
+ # can step up the ladder.
393
+ model: str | None = None
394
+ # Parsed structured plan from a plan-only worker (type="plan"). Stored as
395
+ # a plain dict (the serialised form of WorkerPlan.to_dict()) so it round-
396
+ # trips cleanly through JSON without a custom encoder.
397
+ plan: dict | None = None
398
+ # Review lifecycle state for type="work" assignments.
399
+ # None — not applicable (review/smoke/plan assignments, or pre-feature boards)
400
+ # "pending" — work done, review not yet dispatched
401
+ # "dispatched" — review assignment is in flight
402
+ # "done" — review assignment completed
403
+ review_state: str | None = None
404
+ # #1627: human-readable reason `dispatch_review()` set on THIS assignment
405
+ # the last time it returned ``None`` without dispatching a review — e.g.
406
+ # "assignment is type 'smoke', not reviewable work" or "a work/fix
407
+ # assignment is actively rewriting the branch for this issue". Transient:
408
+ # set in-memory on every early-return guard so a caller in the same
409
+ # process (the `coord review` CLI, chiefly) can report *why* nothing was
410
+ # dispatched instead of guessing. Not read back out of storage by
411
+ # `row_to_assignment` — it isn't meant to survive a reload, only the one
412
+ # dispatch_review() call that produced it.
413
+ review_dispatch_reason: str | None = None
414
+ # Pipeline gate requirements — controls which approval steps are enforced.
415
+ # Empty list means "use config.pipeline.default_gates".
416
+ # Examples: ["review", "merge"], ["merge"], ["review", "smoke", "merge"]
417
+ required_gates: list[str] = field(default_factory=list)
418
+ # Auto-loop iteration counter. For the original work assignment this is 0.
419
+ # Each fix worker dispatched by auto_loop increments this by 1. Used to
420
+ # enforce pipeline.max_review_iterations and stop runaway loops.
421
+ review_iteration: int = 0
422
+ # Timestamp when review findings were successfully posted to GitHub (as a
423
+ # PR review or issue comment). None means findings have not been posted
424
+ # yet — either the review is still running, the worker produced no
425
+ # structured output, or notify never saw the completion event.
426
+ review_posted_at: float | None = None
427
+ # #200: human-driven Test gate verdict for type="work" assignments.
428
+ # None | "passed" | "failed" | "skipped". Review auto-dispatch is gated on
429
+ # this being passed/skipped (or no Test stage configured).
430
+ # #1395: also "running" — a transient, non-verdict marker an unattended
431
+ # driver (scripts/drive-issue.sh) sets while it runs the suite locally
432
+ # (bypassing dispatch_smoke), so coord.stage_projection.test_stage_status_for
433
+ # can show the Test box Active instead of indistinguishable-from-idle
434
+ # Pending. Every gate that reads this field keys off the terminal
435
+ # passed/skipped/failed values explicitly, so "running" fails closed
436
+ # everywhere by construction — never add a bare `is not None` check here.
437
+ test_state: str | None = None
438
+ test_reason: str | None = None
439
+ # #1479: staleness anchor for a terminal (passed/skipped) Test-gate
440
+ # verdict — captured once, best-effort, when the verdict is recorded
441
+ # (``coord.state._record_test_verdict_local``). Mirrors the review gate's
442
+ # ``review_head_sha``/``review_patch_id`` (#821/#1475) but adds a THIRD
443
+ # value the review gate deliberately doesn't need: the merge base's own
444
+ # HEAD SHA at test time. Review staleness is about what changed in the
445
+ # branch; test staleness is also about what the branch was combined
446
+ # with — a rebase onto a moved base can break tests without changing the
447
+ # branch's own diff, so the merge gate (``coord.merge_queue.
448
+ # has_smoke_verdict``) must re-verify even when the content is byte-
449
+ # identical. All three are None for rows predating this feature or where
450
+ # the anchor could not be captured (fails open — the staleness check is
451
+ # skipped, matching #821/#1475's convention).
452
+ test_head_sha: str | None = None
453
+ test_patch_id: str | None = None
454
+ test_base_sha: str | None = None
455
+ # #1629 (H-2): the toolchain that produced this verdict — e.g.
456
+ # "rustc 1.95.0" or "python 3.12.4, node 20.11.0" — captured (best-effort,
457
+ # via coord.health.checks.toolchain) alongside a terminal test_state
458
+ # write. Annotation only: no gate reads this field to block anything (see
459
+ # coord.health.checks.toolchain.probe_toolchain_skew, the fleet-scope
460
+ # check that judges skew — it is advisory, same as every other fleet
461
+ # check). None for every row predating this feature or where the
462
+ # producing toolchain could not be resolved — renders as "unknown", not
463
+ # as a mismatch.
464
+ test_toolchain: str | None = None
465
+ # #253: parsed adversarial-review verdict for type="review" assignments.
466
+ # None | "approve" | "request-changes". Set when notify or auto_loop
467
+ # extracts the structured REVIEW_VERDICT from the reviewer's log; consumed
468
+ # by the merge-queue gate (`has_approved_review`) to refuse merging work
469
+ # whose review has not approved.
470
+ review_verdict: str | None = None
471
+ # #1456: audit trail for a coordinator-side verdict override. When the
472
+ # #476 approve-with-nits gate downgrades a reviewer's "request-changes" to
473
+ # "approve", the reviewer's OWN verdict is preserved here and the evidence
474
+ # that justified the override (the parsed finding counts) in
475
+ # `review_verdict_override_reason`. Both are None for the overwhelming
476
+ # majority of reviews — a non-None `review_verdict_original` is the signal
477
+ # that `review_verdict` is the coordinator's opinion, not the reviewer's.
478
+ # A verdict that changes must be auditable, never overwritten in place: the
479
+ # #1445 incident (a well-formed request-changes silently rewritten to
480
+ # approve) was invisible precisely because only the final value was stored.
481
+ review_verdict_original: str | None = None
482
+ review_verdict_override_reason: str | None = None
483
+ # #1956: provenance for `review_verdict` — WHO decided it and HOW, not
484
+ # just what was decided. A relayed verdict (`coord report-result
485
+ # --verdict` posted by an operator, not parsed from the reviewer's own
486
+ # log) is otherwise indistinguishable from one the reviewer agent
487
+ # produced itself — every downstream reader (merge gate, `coord gates`,
488
+ # the TUI) sees a plain "approve" either way. Three values:
489
+ # "agent" — parsed from the reviewer's own transcript (the
490
+ # overwhelming common case; also the default when this
491
+ # column is NULL, for every row predating this feature).
492
+ # "recovered" — the reviewer reached a verdict and said so in prose,
493
+ # but never emitted the machine-readable REVIEW_VERDICT
494
+ # header; an operator (or the #1956 transcript-floor
495
+ # fallback) rescued it from the transcript. Asserts
496
+ # "the reviewer decided this, we merely restored it."
497
+ # "overridden" — a human (or the #476 approve-with-nits gate) recorded
498
+ # a DIFFERENT verdict than the reviewer's own. Asserts
499
+ # "the reviewer decided otherwise and this overrides
500
+ # it." Always paired with `review_verdict_original`
501
+ # when the override happened automatically (#476); a
502
+ # manual override may have no prior agent verdict at
503
+ # all (e.g. an interactive review that never finished).
504
+ # `verdict_source_reason` is a required, human-readable justification for
505
+ # anything that isn't "agent" — see issue_store._validate_result.
506
+ verdict_source: str | None = None
507
+ verdict_source_reason: str | None = None
508
+ # #821: SHA of the branch HEAD captured at the time the review assignment
509
+ # ran. When set, `has_approved_review` compares this against the merge
510
+ # queue entry's `branch_head_sha` to reject stale approvals — if the
511
+ # branch gained commits after the review ran, the approval no longer
512
+ # covers the current HEAD and the entry is re-blocked until re-reviewed.
513
+ # None for review assignments predating this field or where SHA tracking
514
+ # is not available.
515
+ review_head_sha: str | None = None
516
+ # #1475: content-addressed fingerprint (`git patch-id --stable`) of the
517
+ # diff the review covered, captured alongside `review_head_sha`. When a
518
+ # later commit-bound staleness check finds the SHAs differ (e.g. a
519
+ # conflict-fix rebase moved the head), `has_approved_review` falls back
520
+ # to comparing this against the branch's *current* patch-id — identical
521
+ # ⇒ the rebase changed no content and the approval still covers it;
522
+ # different (or either side unavailable) ⇒ stale, same as before this
523
+ # field existed. Stored alongside `review_head_sha`, never replacing it
524
+ # — the SHA remains the audit/#821 trail. None for rows predating #1475
525
+ # or where the diff/patch-id could not be computed.
526
+ review_patch_id: str | None = None
527
+ # #1476: True when this ``type="review"`` assignment is a SCOPED
528
+ # re-review — dispatched because a conflict-fix rebase changed content
529
+ # under an already-`approve`d review (`review_patch_id` mismatch), with
530
+ # no other intervening work/fix commit. The reviewer was handed the
531
+ # prior approved diff as established context plus only the resolution
532
+ # delta, not the full PR — so a `False`/default here means "read the
533
+ # whole diff" for every audit consumer. `review_scope_base_sha` records
534
+ # the prior review's `review_head_sha`, i.e. which commit the delta was
535
+ # computed FROM, so the audit trail can reconstruct exactly what was
536
+ # (and wasn't) re-read. Both None/False for every row predating this
537
+ # feature and for ordinary full reviews.
538
+ review_scoped: bool = False
539
+ review_scope_base_sha: str | None = None
540
+ # #208: parsed worker cost from the final stream-json `result` event.
541
+ # None means "not yet captured" (older rows, in-flight workers, or
542
+ # workers whose log lacked usage data). Set on completion by
543
+ # notify.py / reconcile via coord.usage.parse_usage_from_log.
544
+ cost_usd: float | None = None
545
+ # #252: worker-emitted smoke-test list parsed from the SMOKE_TESTS
546
+ # block. None = no block emitted (graceful TUI placeholder); [] =
547
+ # explicit "(none — change is internal)"; non-empty list = bullets.
548
+ smoke_tests: list[str] | None = None
549
+ # #324: resolved provider name recorded at dispatch time so the TUI
550
+ # can surface it in the assignment detail panel (#327). None means
551
+ # "dispatched before #324 landed or via a path that doesn't set this
552
+ # field" — the TUI should show the implicit default ("claude") in that
553
+ # case. Always the *resolved* name (after the spec > repo > default
554
+ # precedence chain), not just the raw proposal.provider field.
555
+ provider_name: str | None = None
556
+ # #546: token counts for automated (claude -p) assignments. Parsed from
557
+ # the final stream-json result event at the same time as cost_usd. All
558
+ # default to 0; interactive (Max/OAuth) sessions stay at 0 and the TUI
559
+ # labels them "Max (subscription)" rather than projecting a dollar figure.
560
+ input_tokens: int = 0
561
+ output_tokens: int = 0
562
+ cache_creation_tokens: int = 0
563
+ cache_read_tokens: int = 0
564
+ # #618: short one-liner written immediately when an interactive session
565
+ # fails to launch (e.g. "branch already checked out at <path>"). Lets
566
+ # the TUI explain the red box without any log file being present.
567
+ # None for assignments that launched successfully.
568
+ failure_reason: str | None = None
569
+ # #944: Acceptance-gate verdict (oracle loop, docs/ORACLE_LOOP.md) for
570
+ # type="work" assignments. None | "passed" | "failed" — set by `coord
571
+ # acceptance record --issue N --sha <sha>`, the coordinator's external
572
+ # re-run of the sealed suite against the pushed SHA (the trust gate a
573
+ # headless worker's in-session "green" claim can't fake).
574
+ acceptance_state: str | None = None
575
+ acceptance_reason: str | None = None
576
+ # SHA the last `acceptance record` verdict was recorded against — lets a
577
+ # future gate detect staleness (new commits since the last record) the
578
+ # same way review_head_sha detects a stale review approval.
579
+ acceptance_sha: str | None = None
580
+ # #932: per-test counts from the same verdict, so the Acceptance box can
581
+ # read as partial progress ("3/7 acceptance green") rather than a bare
582
+ # pass/fail — a growing suite is expected to be sub-100% until the
583
+ # feature completes (docs/ORACLE_LOOP.md). None for rows predating #932.
584
+ acceptance_total: int | None = None
585
+ acceptance_passed: int | None = None
586
+ # #874: prose summary extracted from the worker's "### Summary" block
587
+ # and persisted at completion time. NULL when the worker emitted no
588
+ # summary. A durable, board-sourced complement to the ephemeral GitHub
589
+ # comment (which already contained the same text).
590
+ completion_summary: str | None = None
591
+ # #886 Phase 2: Milestone Outcome Audit structured verdict, set only on
592
+ # type="audit" assignments (see #885's --audit-of). audit_goals_json is
593
+ # a raw JSON string — a list of {goal, metric_before, metric_after,
594
+ # verdict, evidence} — deliberately NOT decoded here (mirrors
595
+ # review_findings: the coord-tui client consumes it as Option<String>).
596
+ # audit_run_number increments once per `--audit-of <epic>` run against
597
+ # the same (repo_name, issue_number) so later runs can diff against
598
+ # earlier ones. All None for rows predating this feature.
599
+ audit_goals_json: str | None = None
600
+ audit_bottom_line: str | None = None
601
+ audit_run_number: int | None = None
602
+ # #1084: for type="test-author" assignments, the specific work-order
603
+ # member issue this JIT dispatch is extending the acceptance suite for
604
+ # (`coord.test_author.dispatch_test_author`'s `issue_number` argument).
605
+ # NOT the same as `issue_number` above, which test-author always sets to
606
+ # the milestone's *tracking* issue (every JIT dispatch for a milestone
607
+ # shares one branch/PR, so `issue_number` alone can't distinguish "this
608
+ # is issue #1039's slice" from "issue #1042's slice" — see #1084's
609
+ # friction log). None for milestone-mode (Gate A) authoring and for
610
+ # every other assignment type.
611
+ #
612
+ # #1553: this is now the *attribution* field for the whole oracle-loop
613
+ # slice chain, not just the originating test-author dispatch. Every
614
+ # follow-up derived from a slice (its review, its `[fix-N]` bounces, its
615
+ # smoke, a retry) inherits the same value, either explicitly at the
616
+ # dispatch site or via the parent lookup in
617
+ # `coord.state._record_dispatched_assignment_local`. Read it through
618
+ # :func:`effective_issue_number` rather than by hand so "which issue is
619
+ # this work actually for" has one answer everywhere.
620
+ for_issue_number: int | None = None
621
+ # #1499: durable provenance — set when this assignment was dispatched by
622
+ # `coord drive` (never by a hand `coord assign`). Carries
623
+ # `f"drive:{repo_name}#{issue_number}"` (the DRIVEN issue, which for most
624
+ # assignment types is the same repo/issue this row is already keyed on,
625
+ # but is spelled out explicitly so the value is self-describing on its
626
+ # own in the audit log / board without a join). `None` means "dispatched
627
+ # by hand" (or predates this column) — the whole point is that a drive's
628
+ # own dispatches are distinguishable from a human's after the driver
629
+ # process has exited and left nothing else behind. Threaded through
630
+ # `coord assign --driven-by` (`coord.commands.dispatch.assign`), which
631
+ # `coord/drive.py`'s work-stage `Action` sets on every `coord assign` it
632
+ # shells out to.
633
+ driven_by: str | None = None
634
+
635
+
636
+ def effective_issue_number(assignment: "Assignment | dict") -> int:
637
+ """The issue this assignment's work is *attributed* to (#1553).
638
+
639
+ For ordinary work this is simply ``issue_number``. For oracle-loop
640
+ acceptance-slice work (``coord acceptance author <repo> <tracking>
641
+ --issue N`` and everything derived from it — its review, its
642
+ ``[fix-N]`` bounces, its smoke, a retry) ``issue_number`` is the
643
+ milestone's **tracking/epic** issue, because the whole milestone's JIT
644
+ slices share one branch and one PR. The child issue the work is really
645
+ *for* lives in ``for_issue_number``; this helper prefers it.
646
+
647
+ Both halves are deliberately kept on the row:
648
+
649
+ * ``issue_number`` stays the tracking issue, so the epic keeps its
650
+ parent link, the shared branch/PR bookkeeping keeps working, and
651
+ ``coord.stage_projection``'s merge attribution keeps deliberately
652
+ skipping non-``CLOSES_ISSUE_TYPES`` rows (re-attributing *that* was
653
+ tried in #1203 and reverted in #1652 — do not repeat it).
654
+ * ``for_issue_number`` is the effective/attributed issue, which is what
655
+ "is this child being worked on right now?" and "what did this child
656
+ cost?" must key on.
657
+
658
+ Accepts either an :class:`Assignment` or a wire/DB ``dict`` row so the
659
+ board-JSON consumers (``coord.usage_rollup``) share one definition.
660
+ Returns ``0`` for a row carrying neither field rather than raising —
661
+ every caller here is a display/aggregation path.
662
+
663
+ Readers that deliberately still key on the RAW ``issue_number``, so a
664
+ future change doesn't "fix" them by accident:
665
+
666
+ * ``coord.stage_projection.compute_board_stage_projection`` — the
667
+ per-issue stage grouping and its merge-queue attribution. #1203 tried
668
+ re-attributing that half by ``for_issue_number`` and #1652 reverted it
669
+ (it moved a false "merged/Done" green from the epic onto the child).
670
+ * ``coord.notify`` — ``_pipeline_heads``/``_has_live_session_for`` and
671
+ every GitHub-comment target. Stall notices are posted to the issue
672
+ whose branch/PR the work lives on, which for a slice IS the tracking
673
+ issue; re-keying only the heads would desync the two.
674
+ * ``coord.pipeline.PipelineView.issue_number`` — the CLI's per-assignment
675
+ pipeline view, which reports the row as dispatched.
676
+
677
+ ``coord.claim.has_active_work_followup`` itself keys its scan on the
678
+ effective issue (it calls this helper internally), so BOTH of its
679
+ callers — ``coord.review.dispatch_review`` and
680
+ ``coord.review.dispatch_pending_reviews`` — must pass
681
+ ``effective_issue_number(...)`` too, not the raw ``.issue_number``.
682
+ Passing the raw value there silently reopens the #459 stale-review
683
+ guard for exactly the oracle-loop slices this function exists for: an
684
+ in-flight ``type="work"`` retry for child A (carrying
685
+ ``for_issue_number``) would no longer be detected as "actively
686
+ rewriting the branch" when checking a completed round for that same
687
+ child, because the two sides would be comparing effective-vs-raw
688
+ instead of effective-vs-effective.
689
+ """
690
+ if isinstance(assignment, dict):
691
+ raw_for = assignment.get("for_issue_number")
692
+ raw_own = assignment.get("issue_number")
693
+ else:
694
+ raw_for = getattr(assignment, "for_issue_number", None)
695
+ raw_own = getattr(assignment, "issue_number", None)
696
+ for raw in (raw_for, raw_own):
697
+ if raw is None or raw == "":
698
+ continue
699
+ try:
700
+ return int(raw)
701
+ except (TypeError, ValueError):
702
+ continue
703
+ return 0
704
+
705
+
706
+ @dataclass
707
+ class Proposal:
708
+ id: int
709
+ machine_name: str
710
+ repo_name: str
711
+ issue_number: int
712
+ issue_title: str
713
+ rationale: str
714
+ files_likely: list[str] = field(default_factory=list)
715
+ briefing: str = ""
716
+ # Optional model override. When None, the dispatcher falls back to
717
+ # config.models.default.
718
+ model: str | None = None
719
+ # "work" (default) or "plan". Plan proposals dispatch read-only planning
720
+ # workers that analyse the codebase and produce a structured plan without
721
+ # writing any code.
722
+ type: str = "work"
723
+ # Pipeline gate requirements — mirrors Assignment.required_gates.
724
+ # Set by the coordinator before dispatch so the ledger records intent.
725
+ required_gates: list[str] = field(default_factory=list)
726
+ # Optional explicit branch the agent must check out, bypassing the
727
+ # slugified-title-derived branch name. Used by follow-up dispatches
728
+ # (pr, fix-up, continuation) so prefixed issue titles like
729
+ # `[fix-1] …` or `[conflict-fix] …` don't push to a new orphan
730
+ # branch — the worker must land commits on the parent assignment's
731
+ # branch instead.
732
+ target_branch: str | None = None
733
+ # #315: when set, the dispatch payload includes `--resume <session_id>`
734
+ # so the worker loads the prior claude conversation and continues it.
735
+ # Only set by `coord chat-continue`; regular dispatches leave this None.
736
+ resume_session_id: str | None = None
737
+ # #324: optional provider override for this proposal's worker. Mirrors
738
+ # ``Repo.provider`` and ``AssignmentSpec.provider`` — uses the same
739
+ # precedence chain: spec > repo > providers.default. When None the
740
+ # coordinator and agent both fall back to the global default. Set by
741
+ # ``coord assign --provider`` (#1707, the human escape hatch — validated
742
+ # against ``providers.definitions`` at the CLI before it ever reaches
743
+ # here) for a hand dispatch; brain-side automatic selection is not yet
744
+ # implemented (deliberately out of scope for #1707).
745
+ provider: str | None = None
746
+ # #934: the GitHub Milestone number the target issue belongs to, when
747
+ # known. Set by callers that already fetched the issue (e.g.
748
+ # ``coord.milestone_dispatch.dispatch_entry``) so ``coord.dispatch.
749
+ # dispatch()`` can resolve the worker's base branch via
750
+ # ``coord.branch_model.resolve_base_branch`` — `feature/ms-NN` for a repo
751
+ # that opted into the #934 git model, otherwise the existing
752
+ # `default_branch` behavior. `None` (the default) preserves today's
753
+ # behavior exactly: brain-proposed and other non-milestone-aware
754
+ # dispatches are unaffected.
755
+ milestone_number: int | None = None
756
+ # #1430: the issue's GitHub label names, when the caller already fetched
757
+ # them (avoids a redundant GH call). ``coord.dispatch.dispatch()``
758
+ # consults this via ``config.models.model_for_labels()`` to resolve
759
+ # ``models.labels`` for ``type="work"`` proposals when *model* wasn't
760
+ # already set by the caller. Empty by default — callers that don't
761
+ # populate it simply get today's ``models.default`` behavior.
762
+ issue_labels: list[str] = field(default_factory=list)
763
+ # #1499: mirrors `Assignment.driven_by` — set by `coord/drive.py`'s
764
+ # work-stage dispatch so the resulting assignment row (recorded via
765
+ # `_record_dispatched_local`, the Proposal-based INSERT path every plain
766
+ # non-`--interactive` `coord assign` — including `coord drive`'s — goes
767
+ # through) carries durable provenance. `None` for every other caller
768
+ # (brain-proposed, milestone dispatch, ...).
769
+ driven_by: str | None = None
770
+
771
+
772
+ @dataclass
773
+ class SplitChunk:
774
+ title: str
775
+ scope: str
776
+ files_likely: list[str] = field(default_factory=list)
777
+
778
+
779
+ @dataclass
780
+ class SplitProposal:
781
+ id: int
782
+ repo_name: str
783
+ issue_number: int
784
+ issue_title: str
785
+ rationale: str
786
+ chunks: list[SplitChunk] = field(default_factory=list)
787
+
788
+
789
+ @dataclass
790
+ class Board:
791
+ repos: list[Repo] = field(default_factory=list)
792
+ machines: list[Machine] = field(default_factory=list)
793
+ active: list[Assignment] = field(default_factory=list)
794
+ completed: list[Assignment] = field(default_factory=list)
795
+ round_number: int = 0
796
+
797
+ def repo(self, name: str) -> Repo | None:
798
+ return next((r for r in self.repos if r.name == name), None)
799
+
800
+ def machine(self, name: str) -> Machine | None:
801
+ return next((m for m in self.machines if m.name == name), None)
802
+
803
+ def idle_machines(self) -> list[Machine]:
804
+ busy = {a.machine_name for a in self.active if a.status == "running"}
805
+ return [m for m in self.machines if m.name not in busy]
806
+
807
+ def active_files_by_repo(self) -> dict[str, list[str]]:
808
+ """Map of repo_name -> files currently being touched by running assignments."""
809
+ result: dict[str, list[str]] = {}
810
+ for a in self.active:
811
+ if a.status != "running":
812
+ continue
813
+ result.setdefault(a.repo_name, []).extend(a.files_allowed)
814
+ return result
815
+
816
+ def mark_done(
817
+ self,
818
+ machine_name: str,
819
+ branch: str | None = None,
820
+ pr_url: str | None = None,
821
+ ) -> Assignment | None:
822
+ for a in self.active:
823
+ if a.machine_name == machine_name and a.status == "running":
824
+ a.status = "done"
825
+ a.branch = branch
826
+ a.pr_url = pr_url
827
+ self.completed.append(a)
828
+ self.active.remove(a)
829
+ return a
830
+ return None
831
+
832
+ def mark_failed(self, machine_name: str) -> Assignment | None:
833
+ for a in self.active:
834
+ if a.machine_name == machine_name and a.status == "running":
835
+ a.status = "failed"
836
+ self.completed.append(a)
837
+ self.active.remove(a)
838
+ return a
839
+ return None
840
+
841
+ def find_by_id(self, assignment_id: str) -> Assignment | None:
842
+ for a in self.active:
843
+ if a.assignment_id == assignment_id:
844
+ return a
845
+ for a in self.completed:
846
+ if a.assignment_id == assignment_id:
847
+ return a
848
+ return None
849
+
850
+ def mark_done_by_id(
851
+ self,
852
+ assignment_id: str,
853
+ branch: str | None = None,
854
+ pr_url: str | None = None,
855
+ finished_at: float | None = None,
856
+ ) -> Assignment | None:
857
+ for a in self.active:
858
+ if a.assignment_id == assignment_id:
859
+ a.status = "done"
860
+ if branch is not None:
861
+ a.branch = branch
862
+ if pr_url is not None:
863
+ a.pr_url = pr_url
864
+ a.finished_at = finished_at
865
+ self.completed.append(a)
866
+ self.active.remove(a)
867
+ return a
868
+ return None
869
+
870
+ def mark_failed_by_id(
871
+ self,
872
+ assignment_id: str,
873
+ finished_at: float | None = None,
874
+ ) -> Assignment | None:
875
+ for a in self.active:
876
+ if a.assignment_id == assignment_id:
877
+ a.status = "failed"
878
+ a.finished_at = finished_at
879
+ self.completed.append(a)
880
+ self.active.remove(a)
881
+ return a
882
+ return None
883
+
884
+ def gc(self, keep: int = 50) -> int:
885
+ """Remove oldest completed assignments beyond *keep*. Returns count removed."""
886
+ if len(self.completed) <= keep:
887
+ return 0
888
+ by_time = sorted(self.completed, key=lambda a: a.finished_at or 0)
889
+ to_remove = len(self.completed) - keep
890
+ self.completed = by_time[to_remove:]
891
+ return to_remove