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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- 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
|