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/gate_a.py ADDED
@@ -0,0 +1,469 @@
1
+ """Gate A human sign-off (#2063) — the recorded verdict on a milestone's
2
+ black-box contract.
3
+
4
+ ``docs/ORACLE_LOOP.md`` Phase 0 says a human signs off on the Gate-A mock +
5
+ ``contract.md`` before anything downstream is built against it. Until #2063
6
+ that was a *convention*: "merging the Gate-A PR is sign-off". Anything that
7
+ can merge a PR satisfied it — including a coordinator session, silently, on
8
+ CI green — and nothing downstream ever checked. It failed twice on
9
+ consecutive coord-portal milestones (ms-1 / PR #18, ms-2 / PR #35).
10
+
11
+ This module is the durable half of the fix, mirroring the sibling gate that
12
+ *is* enforced (``coord test --passed|--fail`` → ``PipelineConfig.
13
+ test_precedes_review``):
14
+
15
+ - :class:`GateAApproval` — one board-recorded verdict per
16
+ ``(repo_name, milestone_number)``, persisted by
17
+ :func:`coord.state.save_gate_a_approval` under the ``gate_a_approvals``
18
+ ``board_meta`` key (same seam and shape as ``milestone_gates``, #1929).
19
+ - :func:`contract_digest` — the verdict is keyed to the **content** of
20
+ ``tests/acceptance/ms-NN/contract.md``, so ``coord acceptance mock
21
+ --amend`` automatically invalidates a prior approval. Approving v1 must
22
+ not silently approve v2 — that is the same failure mode this issue is
23
+ about, one level up.
24
+ - :func:`evaluate` — the pure "may work dispatch against this contract"
25
+ decision consumed by
26
+ :func:`coord.milestone_dispatch.issue_oracle_ready` (#1138), which is
27
+ where the refusal actually bites. Enforcing at the *consumer* rather than
28
+ at the merge is deliberate: the Gate-A PR is merged with ``gh pr merge``,
29
+ outside coord entirely, so no coord-side check ever sees it.
30
+ - :func:`park_marker` / :func:`parse_park_marker` — the machine-readable
31
+ tag carried in the refusal prose so ``coord drive-queue``'s tick can
32
+ **park** (re-checked every tick, #1891/#1892) rather than **block** (a
33
+ terminal state nothing re-evaluates, #2040) on an unapproved contract.
34
+ This is explicitly an operator-fixable condition with a one-command
35
+ remedy; a queue entry that went terminal here would stay dead after the
36
+ human approved.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import hashlib
42
+ import re
43
+ import time
44
+ from dataclasses import dataclass
45
+ from typing import Any, Iterable
46
+
47
+ __all__ = [
48
+ "VERDICT_APPROVED",
49
+ "VERDICT_CHANGES",
50
+ "VERDICTS",
51
+ "STATE_APPROVED",
52
+ "STATE_MISSING",
53
+ "STATE_STALE",
54
+ "STATE_CHANGES",
55
+ "STATE_EXEMPT",
56
+ "GateAApproval",
57
+ "GateADecision",
58
+ "contract_digest",
59
+ "short_digest",
60
+ "evaluate",
61
+ "summarise",
62
+ "make_record",
63
+ "approval_fingerprint",
64
+ "NO_VERDICT",
65
+ "park_marker",
66
+ "parse_park_marker",
67
+ "is_gate_a_refusal_reason",
68
+ ]
69
+
70
+ #: The two verdicts an operator can record, mirroring ``coord test
71
+ #: --passed|--fail``. ``changes`` is not merely "not approved": it is a
72
+ #: recorded *rejection*, so the refusal message can say "you asked for
73
+ #: changes — re-run `coord acceptance mock --amend`" rather than the far
74
+ #: less useful "nobody has looked yet".
75
+ VERDICT_APPROVED = "approved"
76
+ VERDICT_CHANGES = "changes"
77
+ VERDICTS: frozenset[str] = frozenset({VERDICT_APPROVED, VERDICT_CHANGES})
78
+
79
+ #: :attr:`GateADecision.state` values.
80
+ STATE_APPROVED = "approved" # a human signed off on exactly this contract
81
+ STATE_MISSING = "missing" # contract exists, nobody has recorded anything
82
+ STATE_STALE = "stale" # approved, but the contract changed since (an --amend)
83
+ STATE_CHANGES = "changes" # a human read it and asked for changes
84
+ STATE_EXEMPT = "exempt" # the milestone declared it needs no human eye
85
+
86
+ _SCHEMA = 1
87
+
88
+
89
+ def contract_digest(text: str | bytes) -> str:
90
+ """Stable content hash of a ``contract.md``.
91
+
92
+ Line endings are normalised and trailing whitespace on the document is
93
+ stripped before hashing, so a CRLF checkout or an editor that adds a
94
+ final newline does not silently invalidate an approval — but any change
95
+ to the *pinned surface* (button text, ``data-testid`` hooks, status
96
+ vocabulary) does. That asymmetry is the whole point: those strings
97
+ become assertions in a sealed suite the worker may never edit, so an
98
+ ``--amend`` that rewords one of them must force a fresh look.
99
+ """
100
+ if isinstance(text, bytes):
101
+ text = text.decode("utf-8", errors="replace")
102
+ normalised = text.replace("\r\n", "\n").replace("\r", "\n").strip()
103
+ return hashlib.sha256(normalised.encode("utf-8")).hexdigest()
104
+
105
+
106
+ def short_digest(sha: str | None) -> str:
107
+ """First 12 hex chars of *sha* — what humans see in messages/UI."""
108
+ return (sha or "")[:12] or "?"
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class GateAApproval:
113
+ """One recorded human verdict on a milestone's Gate-A contract.
114
+
115
+ Stored as a plain JSON dict (:meth:`to_dict`) in the
116
+ ``gate_a_approvals`` ``board_meta`` list, keyed on
117
+ ``(repo_name, milestone_number)``. ``contract_sha`` is what makes the
118
+ verdict *specific*: :func:`evaluate` compares it against the contract
119
+ live on the default branch and downgrades a mismatch to
120
+ :data:`STATE_STALE`.
121
+ """
122
+
123
+ repo_name: str
124
+ milestone_number: int
125
+ verdict: str = VERDICT_APPROVED
126
+ contract_sha: str = ""
127
+ tracking_issue: int | None = None
128
+ note: str = ""
129
+ actor: str = ""
130
+ recorded_at: float = 0.0
131
+ schema: int = _SCHEMA
132
+
133
+ def to_dict(self) -> dict[str, Any]:
134
+ return {
135
+ "repo_name": self.repo_name,
136
+ "milestone_number": self.milestone_number,
137
+ "verdict": self.verdict,
138
+ "contract_sha": self.contract_sha,
139
+ "tracking_issue": self.tracking_issue,
140
+ "note": self.note,
141
+ "actor": self.actor,
142
+ "recorded_at": self.recorded_at,
143
+ "schema": self.schema,
144
+ }
145
+
146
+ @classmethod
147
+ def from_dict(cls, raw: Any) -> "GateAApproval | None":
148
+ """Tolerant decode — ``None`` for anything this build can't read.
149
+
150
+ Same posture as :meth:`coord.milestone_gate.GateRecord.from_dict`: a
151
+ record written by a newer schema, or a corrupt one, degrades to "no
152
+ approval recorded" (which *refuses*, safely) rather than crashing
153
+ dispatch.
154
+ """
155
+ if not isinstance(raw, dict):
156
+ return None
157
+ if int(raw.get("schema", _SCHEMA) or _SCHEMA) != _SCHEMA:
158
+ return None
159
+ repo_name = raw.get("repo_name")
160
+ if not isinstance(repo_name, str) or not repo_name:
161
+ return None
162
+ milestone = _as_int(raw.get("milestone_number"))
163
+ if milestone is None:
164
+ return None
165
+ verdict = str(raw.get("verdict") or VERDICT_APPROVED)
166
+ if verdict not in VERDICTS:
167
+ return None
168
+ return cls(
169
+ repo_name=repo_name,
170
+ milestone_number=milestone,
171
+ verdict=verdict,
172
+ contract_sha=str(raw.get("contract_sha") or ""),
173
+ tracking_issue=_as_int(raw.get("tracking_issue")),
174
+ note=str(raw.get("note") or ""),
175
+ actor=str(raw.get("actor") or ""),
176
+ recorded_at=float(raw.get("recorded_at") or 0.0),
177
+ )
178
+
179
+
180
+ def _as_int(value: object) -> int | None:
181
+ try:
182
+ return int(value) # type: ignore[arg-type]
183
+ except (TypeError, ValueError):
184
+ return None
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class GateADecision:
189
+ """The pure verdict on "may anything be built against this contract".
190
+
191
+ ``ok`` is ``True`` iff a human signed off on *exactly* the contract
192
+ currently on the default branch (or the milestone declared itself
193
+ exempt). ``reason`` is ``None`` iff ``ok``.
194
+ """
195
+
196
+ state: str = STATE_MISSING
197
+ ok: bool = False
198
+ contract_sha: str = ""
199
+ approval: GateAApproval | None = None
200
+ reason: str | None = None
201
+ #: The manifest's ``gate_a: {exempt: true, reason: "..."}`` text, when
202
+ #: ``state`` is :data:`STATE_EXEMPT` — ``""`` otherwise (including when
203
+ #: exempt but no reason was declared). The Proposed-shape item this
204
+ #: implements calls the opt-out "explicit and declared... reviewable" —
205
+ #: that only holds if the reason an operator wrote down is actually
206
+ #: surfaced somewhere (``coord gate-a``, the TUI), not just parsed and
207
+ #: discarded.
208
+ exempt_reason: str = ""
209
+
210
+
211
+ def make_record(
212
+ *,
213
+ repo_name: str,
214
+ milestone_number: int,
215
+ verdict: str,
216
+ contract_sha: str,
217
+ tracking_issue: int | None = None,
218
+ note: str = "",
219
+ actor: str = "",
220
+ now: float | None = None,
221
+ ) -> GateAApproval:
222
+ """Build a verdict record, stamping ``recorded_at``.
223
+
224
+ ``verdict`` must be one of :data:`VERDICTS`; anything else is a
225
+ programming error, not an operator error (the CLI's mutually-exclusive
226
+ ``--approved``/``--changes`` flags already reject bad input).
227
+ """
228
+ if verdict not in VERDICTS:
229
+ raise ValueError(f"unknown Gate A verdict {verdict!r}")
230
+ return GateAApproval(
231
+ repo_name=repo_name,
232
+ milestone_number=int(milestone_number),
233
+ verdict=verdict,
234
+ contract_sha=contract_sha,
235
+ tracking_issue=tracking_issue,
236
+ note=note,
237
+ actor=actor,
238
+ recorded_at=time.time() if now is None else now,
239
+ )
240
+
241
+
242
+ # ── the park marker ─────────────────────────────────────────────────────────
243
+ #
244
+ # The refusal prose is the ONLY channel that survives the process boundary
245
+ # between the guard (`coord assign`, exit 5) and the drive-queue tick, which
246
+ # reads it back out of the `drive_exited` audit row. `coord.merge_queue`'s
247
+ # `CI_PENDING_PREFIX`/`is_ci_pending_reason` pair set the precedent for
248
+ # classifying a refusal from its prose; this goes one step further and
249
+ # embeds the (repo, milestone) the tick needs to re-check the approval
250
+ # cheaply — a local board read, no `gh` call per parked entry per tick.
251
+
252
+ _PARK_MARKER_RE = re.compile(
253
+ r"\[gate-a-approval repo=([^\s\]]+) ms-(\d+) v=([0-9a-f]+|none)\]"
254
+ )
255
+
256
+ #: The fingerprint stamped into the marker when no verdict exists at all.
257
+ NO_VERDICT = "none"
258
+
259
+
260
+ def approval_fingerprint(approval: "GateAApproval | dict | None") -> str:
261
+ """A short, stable fingerprint of the *stored verdict* (not the contract).
262
+
263
+ This is what makes the drive-queue un-park predicate exact. "Resume when
264
+ a verdict exists" would loop forever on a ``--changes`` verdict: the
265
+ guard refuses on it too, so the entry would resume, relaunch, refuse and
266
+ re-park every tick. Resuming only when the stored verdict has *changed
267
+ since the park* bounds it at exactly one relaunch per operator action —
268
+ which is the correct amount, because an operator action is precisely
269
+ what might have cleared it.
270
+ """
271
+ record = (
272
+ GateAApproval.from_dict(approval) if isinstance(approval, dict) else approval
273
+ )
274
+ if record is None:
275
+ return NO_VERDICT
276
+ raw = f"{record.verdict}|{record.contract_sha}|{record.recorded_at!r}"
277
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
278
+
279
+
280
+ def park_marker(
281
+ repo_name: str, milestone_number: int, fingerprint: str = NO_VERDICT
282
+ ) -> str:
283
+ """The machine-readable tag embedded in a Gate-A refusal reason."""
284
+ return (
285
+ f"[gate-a-approval repo={repo_name} ms-{int(milestone_number)} "
286
+ f"v={fingerprint or NO_VERDICT}]"
287
+ )
288
+
289
+
290
+ def parse_park_marker(text: str | None) -> tuple[str, int, str] | None:
291
+ """``(repo_name, milestone_number, fingerprint)`` from a marked reason.
292
+
293
+ ``None`` when *text* is not a Gate-A refusal — which is what makes this
294
+ safe to call over every parked queue entry's ``last_reason``.
295
+ """
296
+ if not text:
297
+ return None
298
+ m = _PARK_MARKER_RE.search(text)
299
+ if m is None:
300
+ return None
301
+ return m.group(1), int(m.group(2)), m.group(3)
302
+
303
+
304
+ def is_gate_a_refusal_reason(text: str | None) -> bool:
305
+ """Whether *text* is a Gate-A "not approved" refusal.
306
+
307
+ ``coord.drive_queue._reconcile_running`` consults this **before** the
308
+ ``exit_refused`` → ``blocked`` branch: unlike the refusals that branch
309
+ was written for (#1844), this one has a one-command operator remedy and
310
+ self-clears, so it must park (#1891 semantics) rather than land in
311
+ terminal ``blocked``, which nothing re-evaluates (#2040).
312
+ """
313
+ return parse_park_marker(text) is not None
314
+
315
+
316
+ def evaluate(
317
+ *,
318
+ repo_name: str,
319
+ milestone_number: int,
320
+ contract_text: str | None,
321
+ approval: GateAApproval | dict | None,
322
+ exempt: bool = False,
323
+ exempt_reason: str = "",
324
+ ) -> GateADecision:
325
+ """Pure Gate-A sign-off decision — no I/O, no config, no GitHub.
326
+
327
+ *contract_text* is the contract as it exists on the repo's default
328
+ branch (``None`` when it could not be read — treated as "cannot verify",
329
+ which refuses). *approval* is the stored record, if any.
330
+
331
+ Returns ``ok=True`` only for :data:`STATE_APPROVED` and
332
+ :data:`STATE_EXEMPT`.
333
+ """
334
+ if exempt:
335
+ return GateADecision(
336
+ state=STATE_EXEMPT,
337
+ ok=True,
338
+ contract_sha=contract_digest(contract_text) if contract_text else "",
339
+ approval=None,
340
+ exempt_reason=exempt_reason,
341
+ )
342
+
343
+ record = (
344
+ GateAApproval.from_dict(approval) if isinstance(approval, dict) else approval
345
+ )
346
+ marker = park_marker(
347
+ repo_name, milestone_number, approval_fingerprint(record)
348
+ )
349
+ ms = f"ms-{milestone_number}"
350
+ contract_path = f"tests/acceptance/{ms}/contract.md"
351
+
352
+ if contract_text is None:
353
+ return GateADecision(
354
+ state=STATE_MISSING,
355
+ ok=False,
356
+ reason=(
357
+ f"Gate A sign-off cannot be verified for {ms}: {contract_path!r} "
358
+ f"could not be read from {repo_name}'s default branch, so there "
359
+ "is nothing to key an approval to. Re-run `coord acceptance mock "
360
+ f"{repo_name} <tracking_issue>` (docs/ORACLE_LOOP.md). {marker}"
361
+ ),
362
+ )
363
+
364
+ sha = contract_digest(contract_text)
365
+
366
+ remedy = (
367
+ f"Read the rendered mock(s) + {contract_path} on the merged Gate-A PR, "
368
+ f"then record the verdict: `coord gate-a --approved {repo_name} "
369
+ "<tracking_issue>` (or `--changes` with a `--note`). Genuinely needs no "
370
+ f"human eye? Declare it: `gate_a: {{exempt: true, reason: ...}}` in "
371
+ f"tests/acceptance/{ms}/manifest.yml."
372
+ )
373
+
374
+ if record is None:
375
+ return GateADecision(
376
+ state=STATE_MISSING,
377
+ ok=False,
378
+ contract_sha=sha,
379
+ reason=(
380
+ f"Gate A has no recorded human sign-off for {ms} "
381
+ f"(contract {short_digest(sha)}). Merging the Gate-A PR is not "
382
+ "sign-off — nothing downstream may be built against a surface "
383
+ f"nobody approved (#2063). {remedy} {marker}"
384
+ ),
385
+ )
386
+
387
+ if record.verdict == VERDICT_CHANGES:
388
+ note = f" Note: {record.note}" if record.note else ""
389
+ if record.contract_sha and record.contract_sha != sha:
390
+ # Changes were requested against an OLDER contract and the
391
+ # contract has since moved — the amend plausibly addressed them,
392
+ # but "plausibly" is exactly what this gate refuses to accept.
393
+ return GateADecision(
394
+ state=STATE_STALE,
395
+ ok=False,
396
+ contract_sha=sha,
397
+ approval=record,
398
+ reason=(
399
+ f"Gate A changes were requested for {ms} against contract "
400
+ f"{short_digest(record.contract_sha)}, and the contract has "
401
+ f"since changed to {short_digest(sha)} — the amend still "
402
+ f"needs a fresh look before anything is built on it.{note} "
403
+ f"{remedy} {marker}"
404
+ ),
405
+ )
406
+ return GateADecision(
407
+ state=STATE_CHANGES,
408
+ ok=False,
409
+ contract_sha=sha,
410
+ approval=record,
411
+ reason=(
412
+ f"Gate A was reviewed for {ms} and a human asked for changes to "
413
+ f"contract {short_digest(sha)}.{note} Amend it with `coord "
414
+ f"acceptance mock {repo_name} <tracking_issue> --amend "
415
+ '"<what to change>"`, then re-record the verdict with `coord '
416
+ f"gate-a --approved {repo_name} <tracking_issue>`. {marker}"
417
+ ),
418
+ )
419
+
420
+ if record.contract_sha != sha:
421
+ return GateADecision(
422
+ state=STATE_STALE,
423
+ ok=False,
424
+ contract_sha=sha,
425
+ approval=record,
426
+ reason=(
427
+ f"Gate A approval for {ms} is stale: a human approved contract "
428
+ f"{short_digest(record.contract_sha)}, but the contract on the "
429
+ f"default branch is now {short_digest(sha)} (an `--amend` landed "
430
+ "since). Approving v1 does not approve v2 — that is the same "
431
+ f"failure this gate exists to prevent (#2063). {remedy} {marker}"
432
+ ),
433
+ )
434
+
435
+ return GateADecision(
436
+ state=STATE_APPROVED, ok=True, contract_sha=sha, approval=record
437
+ )
438
+
439
+
440
+ def summarise(decision: GateADecision) -> str:
441
+ """One-line human summary of *decision*, for ``coord gate-a`` output."""
442
+ if decision.state == STATE_EXEMPT:
443
+ if decision.exempt_reason:
444
+ return (
445
+ "exempt — this milestone declared it needs no human "
446
+ f"sign-off ({decision.exempt_reason})"
447
+ )
448
+ return "exempt — this milestone declared it needs no human sign-off"
449
+ if decision.state == STATE_APPROVED:
450
+ who = decision.approval.actor if decision.approval else ""
451
+ by = f" by {who}" if who else ""
452
+ return f"approved{by} (contract {short_digest(decision.contract_sha)})"
453
+ if decision.state == STATE_STALE:
454
+ return f"stale — contract is now {short_digest(decision.contract_sha)}"
455
+ if decision.state == STATE_CHANGES:
456
+ return "changes requested"
457
+ return "not approved — nobody has recorded a verdict"
458
+
459
+
460
+ def decisions_by_milestone(
461
+ records: Iterable[Any],
462
+ ) -> dict[tuple[str, int], GateAApproval]:
463
+ """Index raw stored dicts by ``(repo_name, milestone_number)``."""
464
+ out: dict[tuple[str, int], GateAApproval] = {}
465
+ for raw in records:
466
+ rec = GateAApproval.from_dict(raw)
467
+ if rec is not None:
468
+ out[(rec.repo_name, rec.milestone_number)] = rec
469
+ return out