agentic-devtools 0.2.577__py3-none-any.whl → 0.2.579__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.
@@ -18,17 +18,22 @@ smallest safe admission set needed to restore that target.
18
18
  ## Entry gate
19
19
 
20
20
  Before reading queue state, reserving admission budget, or dispatching any implementation handoff,
21
- verify an explicit handoff from the separately approved mutation-capable extension. The handoff must
22
- identify this admission role, authorize `drain` or `maintain` mode, and include a verified
23
- supervisor run and checkpoint context. If any authorization, mode, or handoff field is missing,
24
- contradictory, or unverified, return `blocked` without setup; the report-only `audit` baseline never
25
- authorizes this entrypoint.
21
+ verify an explicit handoff from the separately approved mutation-capable extension. The local
22
+ supervision host rooted at `6b1d128d-eef2-4f23-9576-81b56da1c314` may enter without a lease only
23
+ after its trusted, unedited control-issue comment passes the pure host-handoff verifier. All other
24
+ callers retain the existing singleton-lease route; a copied root-session ID or payload never grants
25
+ that exception. Do not call the verifier in GitHub Actions, where the comment writer is
26
+ `github-actions[bot]` under the bookkeeping credential. If the applicable handoff or lease is
27
+ missing, contradictory, or unverified, return `blocked` without setup; the report-only `audit`
28
+ baseline never authorizes this entrypoint.
26
29
 
27
30
  ## Procedure
28
31
 
29
32
  1. Validate the entry handoff and return `blocked` before setup when it does not authorize `drain` or
30
33
  `maintain` mode.
31
- 2. Recount eligible open PRs and read the global budget counters under the orchestrator lease.
34
+ 2. Recount eligible open PRs and read global budget counters. A lease is required for callers outside
35
+ the verified local-host exception; preserve the existing reservation and capacity rechecks for
36
+ every route.
32
37
  3. Revalidate carried-over candidate records with the current run's evidence and dedup marker
33
38
  before ranking them; a prior-run origin alone is not a reason to block or discard a candidate.
34
39
  Rank confirmed issues by security, authorization, data-loss, and correctness impact; then user
@@ -19,10 +19,15 @@ verdicts, provider receipts, budget usage, terminal reason, and a durable checkp
19
19
 
20
20
  ## Procedure
21
21
 
22
- 1. Require a host-provided singleton supervision lease and capture its unique ID. Stop admitting
23
- work after 50 minutes and release the lease at 55 minutes. If the host cannot acquire or release
24
- the lease, return a blocked result before admitting work; this skill and the control CLI do not
25
- implement lease mutations.
22
+ 1. For the local supervision host rooted at
23
+ `6b1d128d-eef2-4f23-9576-81b56da1c314`, accept the lease-free route only after verifying its
24
+ unedited control-issue handoff against the authenticated local GitHub CLI login. Resolve that
25
+ login with `_run_gh(["api", "user"])`, using `DEFAULT_CLASSIC_REPO_WORKFLOW_PAT` when configured
26
+ and otherwise the ambient `gh` credential; pass only that verified login to the verifier. A copied root-session ID or payload does not grant the exception. Do not
27
+ call the verifier in GitHub Actions, where the writer is `github-actions[bot]` using the
28
+ bookkeeping credential. Every other caller must acquire the existing singleton supervision
29
+ lease. Stop admitting work after 50 minutes; release an acquired lease by 55 minutes. If a
30
+ required lease cannot be acquired or released, return a blocked result before further work.
26
31
  2. Delegate shared live workflow and queue evidence collection to the
27
32
  `ai-pr-loop-workflow-monitor` skill. Use its result and shared workflow-monitor record for current
28
33
  workflow runs, managed-PR ordering, and the expected next run; then reconstruct issue, task, PR,
@@ -50,9 +55,9 @@ verdicts, provider receipts, budget usage, terminal reason, and a durable checkp
50
55
  6. If a required operation has no route above (including implementation-PR labeling), record a
51
56
  blocked result naming the missing mutation capability. Do not substitute a direct provider write
52
57
  or claim the operation succeeded.
53
- 7. After lease acquisition, always release the lease on success, blocked, and error paths. Publish
54
- the checkpoint through the control leaf only after all required issue-creation tasks have
55
- completed and their created issues have been verified, then return before the lease deadline.
58
+ 7. If a lease was acquired, release it on success, blocked, and error paths. Publish the checkpoint
59
+ through the control leaf only after all required issue-creation tasks have completed and their
60
+ created issues have been verified, then return before the lease deadline when one applies.
56
61
 
57
62
  ## Safety boundaries
58
63
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.577'
22
- __version_tuple__ = version_tuple = (0, 2, 577)
21
+ __version__ = version = '0.2.579'
22
+ __version_tuple__ = version_tuple = (0, 2, 579)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,145 @@
1
+ """Read-only approver evidence; repository role is not a token permission grant."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from typing import Any, Literal
11
+
12
+ from agentic_devtools.cli.ci.credential_roles import AGDT_PR_APPROVER_PAT
13
+ from agentic_devtools.cli.ci.github_api_error import GitHubApiError
14
+ from agentic_devtools.cli.ci.retry import _provider_rate_limit_error_from_retryable
15
+ from agentic_devtools.cli.shared.retry import ProviderRateLimitError, RetryableError
16
+
17
+ Capability = Literal["identity", "repository_visibility", "repository_role", "review_eligibility"]
18
+
19
+
20
+ class EvidenceStatus(Enum):
21
+ """Whether a read-only capability has been established."""
22
+
23
+ VERIFIED = "verified"
24
+ MISSING = "missing"
25
+ DENIED = "denied"
26
+ UNAVAILABLE = "unavailable"
27
+ UNKNOWN = "unknown"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CapabilityEvidence:
32
+ """Safe evidence; values are validated login or fixed repository-role names."""
33
+
34
+ status: EvidenceStatus = EvidenceStatus.UNKNOWN
35
+ value: str | None = None
36
+ http_status: int | None = None
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ApproverPreflightResult:
41
+ """Independent evidence, with no inferred fine-grained review-write grant."""
42
+
43
+ identity: CapabilityEvidence = field(default_factory=CapabilityEvidence)
44
+ repository_visibility: CapabilityEvidence = field(default_factory=CapabilityEvidence)
45
+ repository_role: CapabilityEvidence = field(default_factory=CapabilityEvidence)
46
+ review_eligibility: CapabilityEvidence = field(default_factory=CapabilityEvidence)
47
+
48
+ @property
49
+ def authenticated_login(self) -> str | None:
50
+ """Return only an authenticated identity, irrespective of review eligibility."""
51
+ return self.identity.value if self.identity.status is EvidenceStatus.VERIFIED else None
52
+
53
+ @property
54
+ def blocking_capability(self) -> Capability | None:
55
+ """Return the first missing, denied, unavailable, or unknown requirement."""
56
+ if not self.identity.value:
57
+ return "identity"
58
+ capabilities: tuple[Capability, ...] = (
59
+ "identity",
60
+ "repository_visibility",
61
+ "repository_role",
62
+ "review_eligibility",
63
+ )
64
+ return next(
65
+ (name for name in capabilities if getattr(self, name).status is not EvidenceStatus.VERIFIED),
66
+ None,
67
+ )
68
+
69
+
70
+ def _read_evidence(
71
+ request: Callable[..., str],
72
+ endpoint: str,
73
+ token: str,
74
+ ) -> tuple[dict[str, Any], CapabilityEvidence]:
75
+ """Read once, converting capability failures but preserving real cooldowns."""
76
+ try:
77
+ response = request(
78
+ endpoint,
79
+ method="GET",
80
+ token=token,
81
+ credential_identity=AGDT_PR_APPROVER_PAT,
82
+ read_pool=False,
83
+ )
84
+ data = json.loads(response)
85
+ if not isinstance(data, dict):
86
+ return {}, CapabilityEvidence()
87
+ return data, CapabilityEvidence(EvidenceStatus.VERIFIED, http_status=200)
88
+ except RetryableError as exc:
89
+ if exc.is_rate_limit:
90
+ raise _provider_rate_limit_error_from_retryable(exc) from exc
91
+ raise
92
+ except ProviderRateLimitError:
93
+ raise
94
+ except GitHubApiError as exc:
95
+ status = EvidenceStatus.DENIED if exc.http_status in {401, 403} else EvidenceStatus.UNAVAILABLE
96
+ return {}, CapabilityEvidence(status, http_status=exc.http_status)
97
+ except Exception:
98
+ # No raw response/exception text is a safe diagnostic at this boundary.
99
+ return {}, CapabilityEvidence()
100
+
101
+
102
+ def _repository_role(data: dict[str, Any]) -> CapabilityEvidence:
103
+ """Read repository permissions without mistaking role for PAT write grants."""
104
+ permissions = data.get("permissions")
105
+ if not isinstance(permissions, dict):
106
+ return CapabilityEvidence()
107
+ for permission, role in (
108
+ ("admin", "admin"),
109
+ ("maintain", "maintain"),
110
+ ("push", "write"),
111
+ ("triage", "triage"),
112
+ ("pull", "read"),
113
+ ):
114
+ if permissions.get(permission) is True:
115
+ status = EvidenceStatus.VERIFIED if role in {"admin", "maintain", "write"} else EvidenceStatus.DENIED
116
+ return CapabilityEvidence(status, role, 200)
117
+ if all(permissions.get(key) is False for key in ("admin", "maintain", "push", "triage", "pull")):
118
+ return CapabilityEvidence(EvidenceStatus.DENIED, http_status=200)
119
+ return CapabilityEvidence()
120
+
121
+
122
+ def collect_approver_preflight(
123
+ request: Callable[..., str],
124
+ base_repo_full_name: str,
125
+ token: str,
126
+ ) -> ApproverPreflightResult:
127
+ """Collect GET-only evidence for the actual base repository, without retries."""
128
+ if not token.strip():
129
+ return ApproverPreflightResult(identity=CapabilityEvidence(EvidenceStatus.MISSING))
130
+ data, identity = _read_evidence(request, "/user", token)
131
+ if identity.status is EvidenceStatus.VERIFIED:
132
+ login = data.get("login")
133
+ identity = (
134
+ CapabilityEvidence(EvidenceStatus.VERIFIED, login, 200)
135
+ if isinstance(login, str) and re.fullmatch(r"[A-Za-z0-9_-]+(?:\[bot\])?", login)
136
+ else CapabilityEvidence()
137
+ )
138
+ if identity.status is not EvidenceStatus.VERIFIED:
139
+ return ApproverPreflightResult(identity=identity)
140
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9-]*/(?!\.\.?$)[A-Za-z0-9_.-]+", base_repo_full_name):
141
+ return ApproverPreflightResult(identity=identity)
142
+ repository, visibility = _read_evidence(request, f"/repos/{base_repo_full_name}", token)
143
+ role = _repository_role(repository) if visibility.status is EvidenceStatus.VERIFIED else CapabilityEvidence()
144
+ # No authoritative read-only source for effective review eligibility is established.
145
+ return ApproverPreflightResult(identity, visibility, role)
@@ -0,0 +1,20 @@
1
+ """Safe, non-retryable GitHub API failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+
8
+ def parse_bare_http_status(stderr: str) -> int | None:
9
+ """Accept a leading CLI HTTP error code, never incidental URL/identifier digits."""
10
+ match = re.match(r"([45]\d{2})\b", stderr)
11
+ return int(match.group(1)) if match else None
12
+
13
+
14
+ class GitHubApiError(RuntimeError):
15
+ """An API rejection with parsed status, never raw response or credential data."""
16
+
17
+ def __init__(self, http_status: int | None = None) -> None:
18
+ self.http_status = http_status
19
+ status = f"HTTP {http_status}" if http_status is not None else "status unavailable"
20
+ super().__init__(f"GitHub API error: {status}")
@@ -26,6 +26,7 @@ from urllib.parse import quote
26
26
  from agentic_devtools.ai_providers.agent_tasks_payload import AGENT_TASKS_ENDPOINT_TEMPLATE
27
27
  from agentic_devtools.cli.ci import critique_adjudicator
28
28
  from agentic_devtools.cli.ci.agent_assignment import AgentAssignmentResult, assign_issue_to_agent
29
+ from agentic_devtools.cli.ci.approver_preflight import ApproverPreflightResult, collect_approver_preflight
29
30
  from agentic_devtools.cli.ci.comment_format import (
30
31
  AI_PR_LOOP_COMMENT_LABEL,
31
32
  format_ai_pr_loop_comment,
@@ -45,6 +46,7 @@ from agentic_devtools.cli.ci.credential_roles import (
45
46
  from agentic_devtools.cli.ci.critique_provider import CritiqueProviderDispatch
46
47
  from agentic_devtools.cli.ci.exceptions import MalformedEventError
47
48
  from agentic_devtools.cli.ci.finalization_state import FinalizedReviewKey, PRCommentFinalizationStateStore
49
+ from agentic_devtools.cli.ci.github_api_error import GitHubApiError, parse_bare_http_status
48
50
  from agentic_devtools.cli.ci.job_logs import UNKNOWN_STEP_SENTINEL
49
51
  from agentic_devtools.cli.ci.models import (
50
52
  COPILOT_COMMENT_LOGINS,
@@ -2455,9 +2457,7 @@ def _gh_api(
2455
2457
  credential_identity=identity,
2456
2458
  is_rate_limit=False,
2457
2459
  )
2458
- raise RuntimeError(
2459
- f"GitHub API error: {_sanitize_gh_error(stderr) if stderr else f'exit code {result.returncode}'}"
2460
- )
2460
+ raise GitHubApiError(status if status is not None else parse_bare_http_status(stderr))
2461
2461
 
2462
2462
  return response_body
2463
2463
 
@@ -2905,6 +2905,7 @@ class GitHubActionsProvider(CIPlatformProvider):
2905
2905
  self._workflow_id_cache: dict[str, str | None] = {}
2906
2906
  self._cloud_writer_run_cache: dict[tuple[int, str, str, tuple[str, ...]], CloudWriterRunInventory] = {}
2907
2907
  self._approver_login_cache: str | None = None
2908
+ self._approver_preflight_cache: dict[str, ApproverPreflightResult] = {}
2908
2909
  self._codeql_configured_cache: dict[tuple[str, str], bool] = {}
2909
2910
  self._pr_token_login_cache: str | None = None
2910
2911
  self._bookkeeping_token_login_cache: str | None = None
@@ -4230,24 +4231,24 @@ class GitHubActionsProvider(CIPlatformProvider):
4230
4231
  )
4231
4232
  return False
4232
4233
 
4233
- try:
4234
- _gh_api(
4235
- self._repo_api(f"/pulls/{pr_number}/reviews"),
4236
- method="POST",
4237
- body={"commit_id": head_sha, "event": "APPROVE", "body": body},
4238
- token=approver_token,
4234
+ _gh_api(
4235
+ self._repo_api(f"/pulls/{pr_number}/reviews"),
4236
+ method="POST",
4237
+ body={"commit_id": head_sha, "event": "APPROVE", "body": body},
4238
+ token=approver_token,
4239
+ )
4240
+ return True
4241
+
4242
+ def get_approver_preflight(self, base_repo_full_name: str) -> ApproverPreflightResult:
4243
+ """Cache read-only evidence by actual base repository for this provider run."""
4244
+ if base_repo_full_name not in self._approver_preflight_cache:
4245
+ result = collect_approver_preflight(
4246
+ _gh_api,
4247
+ base_repo_full_name,
4248
+ os.environ.get(AGDT_PR_APPROVER_PAT, "").strip(),
4239
4249
  )
4240
- return True
4241
- except RuntimeError as exc:
4242
- stderr = str(exc)
4243
- if "401" in stderr or "Bad credentials" in stderr.lower():
4244
- logger.warning(
4245
- "AGDT_PR_APPROVER_PAT authentication failed (401). "
4246
- "The token may be expired or invalid. "
4247
- "Skipping PR approval. Rotate the secret and retry."
4248
- )
4249
- return False
4250
- raise
4250
+ self._approver_preflight_cache[base_repo_full_name] = result
4251
+ return self._approver_preflight_cache[base_repo_full_name]
4251
4252
 
4252
4253
  def get_approver_login(self) -> str:
4253
4254
  """Return the GitHub login for the approver PAT identity.
@@ -0,0 +1,44 @@
1
+ """Focused approval-entry guards, before identity idempotency or review mutation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agentic_devtools.cli.ci.approver_preflight import ApproverPreflightResult
6
+ from agentic_devtools.cli.ci.pipeline.models import ActionDecision, ActionResult
7
+ from agentic_devtools.cli.ci.pipeline.snapshot_models import DerivedState, PRStateSnapshot
8
+
9
+
10
+ def preflight_block(preflight: ApproverPreflightResult | None) -> ActionResult | None:
11
+ """Render only fixed capability/status evidence through the existing summary."""
12
+ if preflight is None:
13
+ return None
14
+ capability = preflight.blocking_capability
15
+ if capability is None:
16
+ return None
17
+ evidence = getattr(preflight, capability)
18
+ status = f"HTTP {evidence.http_status}" if evidence.http_status is not None else "HTTP status unavailable"
19
+ return ActionResult(
20
+ name="approve",
21
+ decision=ActionDecision.BLOCKED,
22
+ preconditions={"approver_preflight": False},
23
+ details=f"Approver preflight blocked: {capability} ({evidence.status.value}; {status})",
24
+ capability=capability,
25
+ http_status=evidence.http_status,
26
+ )
27
+
28
+
29
+ def approval_entry_guard(snapshot: PRStateSnapshot, derived: DerivedState) -> ActionResult | None:
30
+ """Check capabilities before any identity-based decision or live-HEAD guard."""
31
+ blocked = preflight_block(snapshot.approver_preflight)
32
+ if blocked is not None:
33
+ return blocked
34
+ provider = derived.get("provider")
35
+ if provider is not None:
36
+ live_head_sha = provider.get_pr_metadata(snapshot.pr_number).head_sha
37
+ if live_head_sha != snapshot.head_sha:
38
+ return ActionResult(
39
+ "approve",
40
+ ActionDecision.SKIP,
41
+ {"live_head_matches_snapshot": False},
42
+ f"PR HEAD moved from {snapshot.head_sha} to {live_head_sha}",
43
+ )
44
+ return None
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
 
7
+ from agentic_devtools.cli.ci.github_api_error import GitHubApiError
8
+ from agentic_devtools.cli.ci.pipeline.actions.approval_entry import approval_entry_guard, preflight_block
7
9
  from agentic_devtools.cli.ci.pipeline.gate_verdict import (
8
10
  copilot_review_gate_passed,
9
11
  suppressed_deferral_recorded,
@@ -30,18 +32,13 @@ class ApproveAction:
30
32
  Idempotency: when ``snapshot.approver_login`` is known (``AGDT_PR_APPROVER_PAT``
31
33
  resolved via ``GET /user``), the evaluate step short-circuits when that exact
32
34
  identity has already approved the current HEAD (``has_approver_approval_on_head``).
33
- When the approver login cannot be resolved, the idempotency guard is skipped and
34
- ``execute()`` is reached on every run. If the approver token is absent,
35
- ``CIPlatformProvider.approve_pr()`` intentionally skips approval, so no duplicate
36
- reviews are created. Duplicate approvals are only possible when the approver token
37
- is configured and approval still succeeds, but resolving the login via ``GET /user``
38
- failed (for example due to a transient API error). Configure ``AGDT_PR_APPROVER_PAT``
39
- so the precise guard can fire and prevent re-approval.
35
+ Providers supporting read-only preflight must establish every required capability
36
+ before identity idempotency or approval submission. Unknown effective review
37
+ eligibility blocks approval, even when identity and repository role are known.
38
+ Providers without optional preflight retain their legacy login-only behavior.
40
39
 
41
- The idempotency guard is keyed off the *precise* approver identity rather than a
42
- generic non-Copilot approval check to avoid skipping the loop's own required
43
- approval when a human reviewer has approved but is not the loop's approver PAT,
44
- which would leave branch protection unsatisfied.
40
+ Idempotency uses the precise approver identity, not incidental human approvals,
41
+ so an unrelated review cannot satisfy the loop's required approval.
45
42
  """
46
43
 
47
44
  @property
@@ -52,17 +49,11 @@ class ApproveAction:
52
49
  """Evaluate whether approval should be submitted."""
53
50
  preconditions: dict[str, bool] = {}
54
51
 
55
- provider = derived.get("provider")
56
- if provider is not None:
57
- live_head_sha = provider.get_pr_metadata(snapshot.pr_number).head_sha
58
- preconditions["live_head_matches_snapshot"] = live_head_sha == snapshot.head_sha
59
- if not preconditions["live_head_matches_snapshot"]:
60
- return ActionResult(
61
- self.name,
62
- ActionDecision.SKIP,
63
- preconditions,
64
- f"PR HEAD moved from {snapshot.head_sha} to {live_head_sha}",
65
- )
52
+ entry_result = approval_entry_guard(snapshot, derived)
53
+ if entry_result is not None:
54
+ return entry_result
55
+ if derived.get("provider") is not None:
56
+ preconditions["live_head_matches_snapshot"] = True
66
57
 
67
58
  loop_controller = derived.get("loop_controller")
68
59
  loop_state = derived.get("loop_state")
@@ -112,8 +103,8 @@ class ApproveAction:
112
103
 
113
104
  # Precise idempotency guard: skip only when the approver-PAT identity
114
105
  # (resolved at snapshot-build time via GET /user) has already approved the
115
- # current HEAD. Falls through when approver_login is "" (PAT not configured
116
- # or resolution failed) so that execute() remains the idempotency boundary.
106
+ # current HEAD. Optional preflight has already blocked unknown capabilities.
107
+ # Legacy providers without preflight retain the login-only path.
117
108
  if derived.has_approver_approval_on_head:
118
109
  preconditions["no_approver_approval_on_head"] = False
119
110
  return ActionResult(
@@ -219,6 +210,9 @@ class ApproveAction:
219
210
  derived: DerivedState,
220
211
  ) -> ActionResult:
221
212
  """Submit PR approval."""
213
+ blocked = preflight_block(snapshot.approver_preflight)
214
+ if blocked is not None:
215
+ return blocked
222
216
  try:
223
217
  live_head_sha = provider.get_pr_metadata(snapshot.pr_number).head_sha
224
218
  if live_head_sha != snapshot.head_sha:
@@ -232,6 +226,15 @@ class ApproveAction:
232
226
  snapshot.head_sha,
233
227
  "Auto-approved by AI PR loop",
234
228
  )
229
+ except GitHubApiError as exc:
230
+ return ActionResult(
231
+ name=self.name,
232
+ decision=ActionDecision.FAILED,
233
+ error=str(exc),
234
+ details="Review submission failed",
235
+ capability="review_submission",
236
+ http_status=exc.http_status,
237
+ )
235
238
  except Exception as exc:
236
239
  if isinstance(exc, ProviderRateLimitError) and exc.is_rate_limit:
237
240
  raise
@@ -6,6 +6,8 @@ from dataclasses import dataclass, field
6
6
  from enum import Enum
7
7
  from typing import TYPE_CHECKING
8
8
 
9
+ from agentic_devtools.cli.ci.approver_preflight import ApproverPreflightResult as ApproverPreflightResult
10
+
9
11
  if TYPE_CHECKING:
10
12
  from agentic_devtools.cli.ci.pipeline.snapshot_models import PRStateSnapshot
11
13
 
@@ -50,6 +52,8 @@ class ActionResult:
50
52
  allows_file_removal: True only when the action explicitly confirms that
51
53
  removing a baseline file is intentional.
52
54
  allowed_removed_files: Baseline files that may be removed by this action.
55
+ capability: Stable capability identifier for approver preflight/submission.
56
+ http_status: Parsed HTTP status when available; never raw response data.
53
57
 
54
58
  """
55
59
 
@@ -69,6 +73,8 @@ class ActionResult:
69
73
  definitive_no_mutation: bool = False
70
74
  allows_file_removal: bool = False
71
75
  allowed_removed_files: tuple[str, ...] = ()
76
+ capability: str | None = None
77
+ http_status: int | None = None
72
78
 
73
79
 
74
80
  @dataclass
@@ -4,8 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  from dataclasses import replace
7
+ from typing import Protocol, runtime_checkable
7
8
 
8
9
  from agentic_devtools.cli.ci.actionable_checks import get_actionable_check_names
10
+ from agentic_devtools.cli.ci.approver_preflight import ApproverPreflightResult
9
11
  from agentic_devtools.cli.ci.models import PRMetadata, ReviewCommentInfo, ReviewInfo
10
12
  from agentic_devtools.cli.ci.pipeline import critique_snapshot
11
13
  from agentic_devtools.cli.ci.pipeline.gate_verdict import (
@@ -30,10 +32,37 @@ from agentic_devtools.cli.ci.pipeline.snapshot_reviews import (
30
32
  from agentic_devtools.cli.ci.pipeline.snapshot_thread_count import count_unresolved_prior_threads
31
33
  from agentic_devtools.cli.ci.pipeline.snapshot_utils import _raise_if_rate_limit, _supports_diff_fingerprint
32
34
  from agentic_devtools.cli.ci.provider import CIPlatformProvider
35
+ from agentic_devtools.cli.ci.retry import _provider_rate_limit_error_from_retryable
36
+ from agentic_devtools.cli.shared.retry import ProviderRateLimitError, RetryableError
33
37
 
34
38
  logger = logging.getLogger(__name__)
35
39
 
36
40
 
41
+ @runtime_checkable
42
+ class ApproverPreflightProvider(Protocol):
43
+ """Optional read-only approver evidence, independent of CIPlatformProvider."""
44
+
45
+ def get_approver_preflight(self, base_repo_full_name: str) -> ApproverPreflightResult:
46
+ """Return evidence for the actual PR base repository."""
47
+
48
+
49
+ def _get_approver_preflight(provider: CIPlatformProvider, base_repo_full_name: str) -> ApproverPreflightResult | None:
50
+ """Fail closed for supported providers, preserving the existing cooldown path."""
51
+ if not isinstance(provider, ApproverPreflightProvider):
52
+ return None
53
+ try:
54
+ result = provider.get_approver_preflight(base_repo_full_name)
55
+ return result if isinstance(result, ApproverPreflightResult) else ApproverPreflightResult()
56
+ except RetryableError as exc:
57
+ if exc.is_rate_limit:
58
+ raise _provider_rate_limit_error_from_retryable(exc) from exc
59
+ raise
60
+ except ProviderRateLimitError:
61
+ raise
62
+ except Exception:
63
+ return ApproverPreflightResult()
64
+
65
+
37
66
  def _get_head_author(provider: CIPlatformProvider, head_sha: str) -> str:
38
67
  """Resolve the head author login, failing open when the provider lookup fails."""
39
68
  try:
@@ -166,12 +195,13 @@ def _get_approval_state(
166
195
  pr_number: int,
167
196
  reviews: list[ReviewInfo],
168
197
  head_sha: str,
198
+ preflight: ApproverPreflightResult | None = None,
169
199
  ) -> tuple[bool, str, bool]:
170
200
  """Return approval status on HEAD and the best-effort approver identity."""
171
201
  effective_reviews = get_effective_head_reviews(reviews, head_sha)
172
202
  has_approval = any(review.state == "APPROVED" for review in effective_reviews)
173
203
  try:
174
- raw_login = provider.get_approver_login()
204
+ raw_login = preflight.authenticated_login if preflight is not None else provider.get_approver_login()
175
205
  approver_login = raw_login if isinstance(raw_login, str) else ""
176
206
  except Exception as exc:
177
207
  _raise_if_rate_limit(exc)
@@ -275,6 +305,7 @@ def build_pr_state_snapshot(
275
305
  if actionable_check_names is None:
276
306
  actionable_check_names = get_actionable_check_names()
277
307
  pr_meta = provider.get_pr_metadata(pr_number)
308
+ preflight = _get_approver_preflight(provider, pr_meta.base_repo_full_name)
278
309
  head_author_login = _get_head_author(provider, pr_meta.head_sha)
279
310
  files, base_sha, diff_hash, diff_hash_supported, diff_hash_available = _collect_diff_state(
280
311
  provider, pr_number, pr_meta
@@ -303,6 +334,7 @@ def build_pr_state_snapshot(
303
334
  pr_number,
304
335
  reviews,
305
336
  pr_meta.head_sha,
337
+ preflight,
306
338
  )
307
339
  copilot_review, copilot_review_state, copilot_review_id, copilot_review_inline_count, comments = (
308
340
  _get_copilot_review(
@@ -326,7 +358,6 @@ def build_pr_state_snapshot(
326
358
  pr_meta.base_branch,
327
359
  promotion_failure,
328
360
  )
329
- effective_verdict_review_id = copilot_gate_verdict.review_id if copilot_gate_verdict.review_id > 0 else None
330
361
  (
331
362
  unresolved_threads,
332
363
  repairable_threads,
@@ -336,7 +367,7 @@ def build_pr_state_snapshot(
336
367
  provider,
337
368
  pr_number,
338
369
  reviews,
339
- effective_verdict_review_id,
370
+ copilot_gate_verdict.review_id if copilot_gate_verdict.review_id > 0 else None,
340
371
  verdict=copilot_gate_verdict,
341
372
  )
342
373
  head_changed_since_review = _head_changed_since_effective_gate_review(
@@ -413,6 +444,7 @@ def build_pr_state_snapshot(
413
444
  critique_fields = replace(critique_fields, critique_task_state_available=False)
414
445
 
415
446
  return PRStateSnapshot(
447
+ approver_preflight=preflight,
416
448
  pr_number=pr_number,
417
449
  pr_id=pr_meta.pr_id,
418
450
  head_sha=pr_meta.head_sha,
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from dataclasses import dataclass, field
6
6
  from typing import Any
7
7
 
8
+ from agentic_devtools.cli.ci.approver_preflight import ApproverPreflightResult
8
9
  from agentic_devtools.cli.ci.models import CheckRunStatus, ReviewInfo
9
10
  from agentic_devtools.cli.ci.pipeline.critique_snapshot import CritiqueSnapshotFields
10
11
  from agentic_devtools.cli.ci.pipeline.gate_verdict import CopilotGateVerdict
@@ -78,6 +79,8 @@ class PRStateSnapshot(CritiqueSnapshotFields):
78
79
  ``GET /user`` with ``AGDT_PR_APPROVER_PAT``). Empty string when the token
79
80
  is not configured or login resolution failed. Used together with
80
81
  ``has_approver_approval_on_head`` for precise approve-action idempotency.
82
+ approver_preflight: Read-only capability evidence for the actual base repository.
83
+ ``None`` only for providers without the optional preflight capability.
81
84
  has_approver_approval_on_head: Whether the approver-PAT identity has already
82
85
  approved the current HEAD. Computed from ``approver_login`` and the
83
86
  effective HEAD reviews — only ``True`` when ``approver_login`` is non-empty,
@@ -128,6 +131,7 @@ class PRStateSnapshot(CritiqueSnapshotFields):
128
131
  """
129
132
 
130
133
  pr_number: int = 0
134
+ approver_preflight: ApproverPreflightResult | None = field(default=None, kw_only=True)
131
135
  pr_id: int | None = field(default=None, kw_only=True)
132
136
  head_sha: str = ""
133
137
  base_branch: str = ""
@@ -0,0 +1,358 @@
1
+ """Pure validation of authorized local supervision-host admission handoffs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from collections.abc import Collection, Mapping, Sequence
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime, timedelta
11
+ from enum import StrEnum
12
+ from types import MappingProxyType
13
+
14
+ from agentic_devtools._bundled_skills.skills.fingerprint import canonicalize_json
15
+ from agentic_devtools.cli.ci.comment_format import normalize_ai_pr_loop_comment
16
+
17
+ HOST_ROOT_SESSION_ID = "6b1d128d-eef2-4f23-9576-81b56da1c314"
18
+ HOST_HANDOFF_MARKER = "<!-- ai-pr-loop-host-handoff:v1 -->"
19
+ _MARKER_NAMESPACE = "ai-pr-loop-host-handoff"
20
+ _AI_PR_LOOP_LABEL = "🤖 AI PR Loop ·"
21
+ _FIELDS = frozenset(
22
+ {
23
+ "repository",
24
+ "target",
25
+ "admission_role",
26
+ "mode",
27
+ "supervisor_run_id",
28
+ "checkpoint",
29
+ "host_root_session",
30
+ "issued_at",
31
+ "expires_at",
32
+ "fingerprint",
33
+ }
34
+ )
35
+ _EXPECTED_FIELDS = frozenset({"repository", "role", "mode", "supervisor_run_id", "checkpoint"})
36
+ _REPOSITORY_PATTERN = re.compile(r"[^/\s]+/[^/\s]+")
37
+ _FINGERPRINT_PATTERN = re.compile(r"[0-9a-f]{64}")
38
+ _UTC_TIMESTAMP_PATTERN = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.([0-9]+))?Z")
39
+ _COMMENT_TIMESTAMP_PATTERN = re.compile(
40
+ r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})"
41
+ )
42
+ _MAX_LIFETIME_SECONDS = 55 * 60
43
+
44
+
45
+ class HostHandoffRejectionReason(StrEnum):
46
+ """Distinct fail-closed outcomes for host handoff verification."""
47
+
48
+ NO_RECORD = "no_record"
49
+ UNTRUSTED_AUTHOR = "untrusted_author"
50
+ EDITED_COMMENT = "edited_comment"
51
+ MALFORMED_MARKER = "malformed_marker"
52
+ MALFORMED_COMMENT_NODE = "malformed_comment_node"
53
+ MALFORMED_JSON = "malformed_json"
54
+ DUPLICATE_JSON_KEY = "duplicate_json_key"
55
+ UNKNOWN_FIELD = "unknown_field"
56
+ MISSING_FIELD = "missing_field"
57
+ INVALID_FIELD_TYPE = "invalid_field_type"
58
+ NONCANONICAL_JSON = "noncanonical_json"
59
+ TARGET_MISMATCH = "target_mismatch"
60
+ REPOSITORY_MISMATCH = "repository_mismatch"
61
+ ROLE_MISMATCH = "role_mismatch"
62
+ MODE_MISMATCH = "mode_mismatch"
63
+ SUPERVISOR_RUN_MISMATCH = "supervisor_run_mismatch"
64
+ CHECKPOINT_MISMATCH = "checkpoint_mismatch"
65
+ WRONG_HOST_ROOT_SESSION = "wrong_host_root_session"
66
+ INVALID_TIMESTAMP = "invalid_timestamp"
67
+ FUTURE_ISSUED_AT = "future_issued_at"
68
+ STALE_RECORD = "stale_record"
69
+ LIFETIME_EXCEEDED = "lifetime_exceeded"
70
+ FINGERPRINT_MISMATCH = "fingerprint_mismatch"
71
+ MULTIPLE_MATCHING_RECORDS = "multiple_matching_records"
72
+
73
+
74
+ _STRUCTURAL_ORDER = (
75
+ HostHandoffRejectionReason.MALFORMED_MARKER,
76
+ HostHandoffRejectionReason.MALFORMED_COMMENT_NODE,
77
+ HostHandoffRejectionReason.DUPLICATE_JSON_KEY,
78
+ HostHandoffRejectionReason.MALFORMED_JSON,
79
+ HostHandoffRejectionReason.UNKNOWN_FIELD,
80
+ HostHandoffRejectionReason.MISSING_FIELD,
81
+ HostHandoffRejectionReason.INVALID_FIELD_TYPE,
82
+ HostHandoffRejectionReason.NONCANONICAL_JSON,
83
+ )
84
+ _MISMATCH_ORDER = (
85
+ HostHandoffRejectionReason.REPOSITORY_MISMATCH,
86
+ HostHandoffRejectionReason.TARGET_MISMATCH,
87
+ HostHandoffRejectionReason.ROLE_MISMATCH,
88
+ HostHandoffRejectionReason.MODE_MISMATCH,
89
+ HostHandoffRejectionReason.SUPERVISOR_RUN_MISMATCH,
90
+ HostHandoffRejectionReason.CHECKPOINT_MISMATCH,
91
+ )
92
+ _VALIDATION_ORDER = (
93
+ HostHandoffRejectionReason.WRONG_HOST_ROOT_SESSION,
94
+ HostHandoffRejectionReason.EDITED_COMMENT,
95
+ HostHandoffRejectionReason.INVALID_TIMESTAMP,
96
+ HostHandoffRejectionReason.FUTURE_ISSUED_AT,
97
+ HostHandoffRejectionReason.STALE_RECORD,
98
+ HostHandoffRejectionReason.LIFETIME_EXCEEDED,
99
+ HostHandoffRejectionReason.FINGERPRINT_MISMATCH,
100
+ )
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class VerifyHostHandoffResult:
105
+ """Accepted handoff payload or its deterministic rejection reason."""
106
+
107
+ accepted: bool
108
+ reason: HostHandoffRejectionReason | None
109
+ payload: Mapping[str, str] | None
110
+
111
+
112
+ class _HostHandoffVerifier:
113
+ """Validate candidate records after the host boundary has been established."""
114
+
115
+ def __init__(self, expected: Mapping[str, str], trusted_authors: set[str], now: tuple[int, str]) -> None:
116
+ self.expected = expected
117
+ self.trusted_authors = trusted_authors
118
+ self.now = now
119
+ self.epoch = datetime(1970, 1, 1, tzinfo=UTC)
120
+
121
+ def _parse_timestamp(self, value: str) -> tuple[int, str] | None:
122
+ match = _UTC_TIMESTAMP_PATTERN.fullmatch(value)
123
+ if match is None:
124
+ return None
125
+ try:
126
+ whole_seconds = datetime.strptime(value[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=UTC)
127
+ except ValueError:
128
+ return None
129
+ delta = whole_seconds - self.epoch
130
+ return delta.days * 86400 + delta.seconds, (match.group(1) or "").rstrip("0")
131
+
132
+ @staticmethod
133
+ def _valid_comment_timestamp(value: str) -> bool:
134
+ if _COMMENT_TIMESTAMP_PATTERN.fullmatch(value) is None:
135
+ return False
136
+ try:
137
+ parsed = datetime.fromisoformat(value[:-1] + "+00:00" if value.endswith("Z") else value)
138
+ except ValueError:
139
+ return False
140
+ return parsed.tzinfo is not None and parsed.utcoffset() is not None
141
+
142
+ @staticmethod
143
+ def _unwrap_body(body: str) -> tuple[str, HostHandoffRejectionReason | None]:
144
+ normalized = normalize_ai_pr_loop_comment(body)
145
+ if normalized is None or normalized == body:
146
+ return "", HostHandoffRejectionReason.MALFORMED_MARKER
147
+ lines = normalized.split("\n")
148
+ if (
149
+ "\r" in normalized
150
+ or len(lines) != 2
151
+ or lines[0] != HOST_HANDOFF_MARKER
152
+ or not body.endswith(f"\n\n{_AI_PR_LOOP_LABEL}")
153
+ ):
154
+ return "", HostHandoffRejectionReason.MALFORMED_MARKER
155
+ return lines[1], None
156
+
157
+ def _validate_payload(self, json_text: str, payload: dict[str, object]) -> HostHandoffRejectionReason | None:
158
+ if set(payload) - _FIELDS:
159
+ return HostHandoffRejectionReason.UNKNOWN_FIELD
160
+ if _FIELDS - set(payload):
161
+ return HostHandoffRejectionReason.MISSING_FIELD
162
+ fingerprint = payload["fingerprint"]
163
+ if (
164
+ any(type(value) is not str for value in payload.values())
165
+ or not isinstance(fingerprint, str)
166
+ or _FINGERPRINT_PATTERN.fullmatch(fingerprint) is None
167
+ ):
168
+ return HostHandoffRejectionReason.INVALID_FIELD_TYPE
169
+ try:
170
+ canonical_text = canonicalize_json(payload).decode("utf-8")
171
+ except TypeError, ValueError:
172
+ return HostHandoffRejectionReason.NONCANONICAL_JSON
173
+ return None if json_text == canonical_text else HostHandoffRejectionReason.NONCANONICAL_JSON
174
+
175
+ def _parse_payload(
176
+ self,
177
+ json_text: str,
178
+ ) -> tuple[dict[str, str], HostHandoffRejectionReason | None]:
179
+ duplicate_keys: list[str] = []
180
+
181
+ def unique_members(pairs: list[tuple[str, object]]) -> dict[str, object]:
182
+ result: dict[str, object] = {}
183
+ for key, value in pairs:
184
+ if key in result:
185
+ duplicate_keys.append(key)
186
+ result[key] = value
187
+ return result
188
+
189
+ try:
190
+ parsed: object = json.loads(json_text, object_pairs_hook=unique_members)
191
+ except json.JSONDecodeError:
192
+ return {}, (
193
+ HostHandoffRejectionReason.DUPLICATE_JSON_KEY
194
+ if duplicate_keys
195
+ else HostHandoffRejectionReason.MALFORMED_JSON
196
+ )
197
+ if duplicate_keys:
198
+ return {}, HostHandoffRejectionReason.DUPLICATE_JSON_KEY
199
+ if not isinstance(parsed, dict):
200
+ return {}, HostHandoffRejectionReason.MALFORMED_JSON
201
+ reason = self._validate_payload(json_text, parsed)
202
+ if reason is not None:
203
+ return {}, reason
204
+ return {key: value for key, value in parsed.items() if isinstance(value, str)}, None
205
+
206
+ def _parse_candidate(
207
+ self,
208
+ node: Mapping[str, object],
209
+ body: str,
210
+ ) -> tuple[dict[str, str], bool, HostHandoffRejectionReason | None]:
211
+ json_text, reason = self._unwrap_body(body)
212
+ if reason is not None:
213
+ return {}, False, reason
214
+ last_edited_at = node.get("lastEditedAt")
215
+ if "lastEditedAt" not in node or (
216
+ last_edited_at is not None
217
+ and (not isinstance(last_edited_at, str) or not self._valid_comment_timestamp(last_edited_at))
218
+ ):
219
+ return {}, False, HostHandoffRejectionReason.MALFORMED_COMMENT_NODE
220
+ payload, reason = self._parse_payload(json_text)
221
+ if reason is not None:
222
+ return {}, False, reason
223
+ return payload, last_edited_at is not None, None
224
+
225
+ def _context_mismatch(self, payload: Mapping[str, str]) -> HostHandoffRejectionReason | None:
226
+ if (
227
+ _REPOSITORY_PATTERN.fullmatch(payload["repository"]) is None
228
+ or payload["repository"] != self.expected["repository"]
229
+ ):
230
+ return HostHandoffRejectionReason.REPOSITORY_MISMATCH
231
+ if payload["target"] != "admission":
232
+ return HostHandoffRejectionReason.TARGET_MISMATCH
233
+ if payload["admission_role"] != "admission" or payload["admission_role"] != self.expected["role"]:
234
+ return HostHandoffRejectionReason.ROLE_MISMATCH
235
+ if payload["mode"] not in {"drain", "maintain"} or payload["mode"] != self.expected["mode"]:
236
+ return HostHandoffRejectionReason.MODE_MISMATCH
237
+ if payload["supervisor_run_id"] != self.expected["supervisor_run_id"]:
238
+ return HostHandoffRejectionReason.SUPERVISOR_RUN_MISMATCH
239
+ if payload["checkpoint"] != self.expected["checkpoint"]:
240
+ return HostHandoffRejectionReason.CHECKPOINT_MISMATCH
241
+ return None
242
+
243
+ def _validate_candidate(
244
+ self,
245
+ payload: Mapping[str, str],
246
+ edited: bool,
247
+ ) -> list[HostHandoffRejectionReason]:
248
+ failures: list[HostHandoffRejectionReason] = []
249
+ if payload["host_root_session"] != HOST_ROOT_SESSION_ID:
250
+ failures.append(HostHandoffRejectionReason.WRONG_HOST_ROOT_SESSION)
251
+ if edited:
252
+ failures.append(HostHandoffRejectionReason.EDITED_COMMENT)
253
+ issued_at = self._parse_timestamp(payload["issued_at"])
254
+ expires_at = self._parse_timestamp(payload["expires_at"])
255
+ if issued_at is None or expires_at is None:
256
+ failures.append(HostHandoffRejectionReason.INVALID_TIMESTAMP)
257
+ else:
258
+ if issued_at > self.now:
259
+ failures.append(HostHandoffRejectionReason.FUTURE_ISSUED_AT)
260
+ if expires_at <= self.now:
261
+ failures.append(HostHandoffRejectionReason.STALE_RECORD)
262
+ if expires_at > (issued_at[0] + _MAX_LIFETIME_SECONDS, issued_at[1]):
263
+ failures.append(HostHandoffRejectionReason.LIFETIME_EXCEEDED)
264
+ unsigned_payload = dict(payload)
265
+ fingerprint = unsigned_payload.pop("fingerprint")
266
+ expected_fingerprint = hashlib.sha256(canonicalize_json(unsigned_payload)).hexdigest()
267
+ if fingerprint != expected_fingerprint:
268
+ failures.append(HostHandoffRejectionReason.FINGERPRINT_MISMATCH)
269
+ return failures
270
+
271
+ def _collect_candidates(
272
+ self,
273
+ comments: Sequence[object],
274
+ ) -> tuple[list[tuple[dict[str, str], bool]], list[HostHandoffRejectionReason], bool]:
275
+ candidates: list[tuple[dict[str, str], bool]] = []
276
+ structural_failures: list[HostHandoffRejectionReason] = []
277
+ untrusted_candidate = False
278
+ for raw_node in comments:
279
+ node = raw_node if isinstance(raw_node, Mapping) else {}
280
+ body = node.get("body")
281
+ if not isinstance(body, str) or _MARKER_NAMESPACE not in body:
282
+ continue
283
+ author = node.get("author")
284
+ login = author.get("login") if isinstance(author, Mapping) else None
285
+ if not isinstance(login, str) or not login.strip() or login.casefold() not in self.trusted_authors:
286
+ untrusted_candidate = True
287
+ continue
288
+ candidate, edited, reason = self._parse_candidate(node, body)
289
+ if reason is not None:
290
+ structural_failures.append(reason)
291
+ else:
292
+ candidates.append((candidate, edited))
293
+ return candidates, structural_failures, untrusted_candidate
294
+
295
+ def verify(self, comments: Sequence[object]) -> VerifyHostHandoffResult:
296
+ candidates, structural_failures, untrusted_candidate = self._collect_candidates(comments)
297
+ if structural_failures:
298
+ return VerifyHostHandoffResult(False, min(structural_failures, key=_STRUCTURAL_ORDER.index), None)
299
+ if not candidates:
300
+ reason = (
301
+ HostHandoffRejectionReason.UNTRUSTED_AUTHOR
302
+ if untrusted_candidate
303
+ else HostHandoffRejectionReason.NO_RECORD
304
+ )
305
+ return VerifyHostHandoffResult(False, reason, None)
306
+
307
+ matching: list[tuple[dict[str, str], bool]] = []
308
+ mismatches: list[HostHandoffRejectionReason] = []
309
+ for payload, edited in candidates:
310
+ mismatch = self._context_mismatch(payload)
311
+ if mismatch is None:
312
+ matching.append((payload, edited))
313
+ else:
314
+ mismatches.append(mismatch)
315
+ if not matching:
316
+ return VerifyHostHandoffResult(False, min(mismatches, key=_MISMATCH_ORDER.index), None)
317
+
318
+ validation_failures = [
319
+ reason for payload, edited in matching for reason in self._validate_candidate(payload, edited)
320
+ ]
321
+ if validation_failures:
322
+ return VerifyHostHandoffResult(False, min(validation_failures, key=_VALIDATION_ORDER.index), None)
323
+ if len(matching) != 1:
324
+ return VerifyHostHandoffResult(False, HostHandoffRejectionReason.MULTIPLE_MATCHING_RECORDS, None)
325
+ return VerifyHostHandoffResult(True, None, MappingProxyType(dict(matching[0][0])))
326
+
327
+
328
+ def verify_host_handoff(
329
+ comments: Sequence[object],
330
+ *,
331
+ trusted_logins: Collection[str],
332
+ expected: Mapping[str, str],
333
+ now: datetime,
334
+ ) -> VerifyHostHandoffResult:
335
+ """Verify one canonical, unedited control-issue handoff from a trusted local host.
336
+
337
+ The caller must stop before invoking this verifier in GitHub Actions, where
338
+ control comments are written by the separate bookkeeping-token identity.
339
+ """
340
+ if not isinstance(now, datetime) or now.tzinfo is None or now.utcoffset() != timedelta(0):
341
+ raise ValueError("now must be a timezone-aware UTC datetime")
342
+ if (
343
+ not isinstance(expected, Mapping)
344
+ or set(expected) != _EXPECTED_FIELDS
345
+ or any(type(value) is not str for value in expected.values())
346
+ ):
347
+ raise ValueError("expected must contain exactly the five string handoff context fields")
348
+ if isinstance(trusted_logins, (str, bytes)) or not isinstance(trusted_logins, Collection):
349
+ raise ValueError("trusted_logins must be a collection of login strings")
350
+
351
+ trusted_authors = {login.casefold() for login in trusted_logins if isinstance(login, str) and login.strip()}
352
+ now_delta = now.astimezone(UTC) - datetime(1970, 1, 1, tzinfo=UTC)
353
+ now_exact = (
354
+ now_delta.days * 86400 + now_delta.seconds,
355
+ f"{now_delta.microseconds:06d}".rstrip("0"),
356
+ )
357
+ verifier = _HostHandoffVerifier(expected, trusted_authors, now_exact)
358
+ return verifier.verify(comments)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-devtools
3
- Version: 0.2.577
3
+ Version: 0.2.579
4
4
  Summary: Agentic devtools integrate Jira, DevOps & more
5
5
  Author: ayaiayorg
6
6
  License-Expression: MIT
@@ -1,5 +1,5 @@
1
1
  agentic_devtools/__init__.py,sha256=J_Zw_vWKghk-cLmqI83hXQmSiS8zMhGIHM5WPLDkZuo,242
2
- agentic_devtools/_version.py,sha256=TIWdW15l89P8z-XtWYdluJmDXV7ryxUz-CBFY28AsZc,524
2
+ agentic_devtools/_version.py,sha256=ttyJ4aa-Qss_defAUjpwHl__bX0HFcUvkugxDu8chT4,524
3
3
  agentic_devtools/agdt_gitignore.py,sha256=vVVqTjBZ_ijn6U3Lnh_JGrARLschYVN-fFal9jDk0Ak,2694
4
4
  agentic_devtools/background_tasks.py,sha256=sFgmqOnCdCjclABJ8V__yDwBXDyi45b1Z-MVv3G2zB8,17664
5
5
  agentic_devtools/config.py,sha256=DEVxTVZhsQbVQwO9qdB_1h19aBpjMzF6xJY_ePYLGLs,15629
@@ -229,6 +229,7 @@ agentic_devtools/cli/ci/actionable_checks.py,sha256=PICEBWfuD16UgPm5mgRgC55WuE3a
229
229
  agentic_devtools/cli/ci/ado_provider.py,sha256=6FpNOEF7zeeKdYwIgN56v7F7xpUZUYQLHpBiRrDbrSw,18115
230
230
  agentic_devtools/cli/ci/agent_assignment.py,sha256=xALWk47PMo2uW44kgMm6K7EXfQN5NCv3zyyzWScIAfE,46026
231
231
  agentic_devtools/cli/ci/agent_task_inventory.py,sha256=9ATECoO20I-_fVUrjYC0kLmSt_aHw3VpXG26AlLlkVU,3228
232
+ agentic_devtools/cli/ci/approver_preflight.py,sha256=uM_LF0qrf_nMyGJT8CUvdqh0comD_AAsS9Sw_9HqBTA,5865
232
233
  agentic_devtools/cli/ci/commands.py,sha256=REVb_t8Ei4F37JmAn57FafIwzBa_u7PNhKkJHvchx5o,27282
233
234
  agentic_devtools/cli/ci/comment_format.py,sha256=-jbcUa1AtArU95gnGyhQ1asm704j1zYAf1m1bz7WhS8,1415
234
235
  agentic_devtools/cli/ci/conflict_repair_dispatch.py,sha256=nqdRa8DWOPx4DjLVvojUlsG9LmfFiZPtNt9eWxi_NJg,6715
@@ -244,7 +245,8 @@ agentic_devtools/cli/ci/dispatcher_throttle.py,sha256=6vOfb-UQ2uR8AM-l9yjSpgCowB
244
245
  agentic_devtools/cli/ci/due_probe_wakeup.py,sha256=4Tie-_cSFlYlkb6o3kJUZt9RaIiROBd5Crm5QAqSxfY,13494
245
246
  agentic_devtools/cli/ci/exceptions.py,sha256=zpKWOgnBbHHQgAQRvdRW4VFnE_qdgqgmo8r1H-x47l4,1086
246
247
  agentic_devtools/cli/ci/finalization_state.py,sha256=jQm_cH0J09LCZfEUdZK2XQNZFhF7KhyilKcGZ4g8Wzc,8978
247
- agentic_devtools/cli/ci/github_provider.py,sha256=pHOcZs_JVgCwTjTrHh-tUghNnfRYgmsEVD5I_zzj61Y,501276
248
+ agentic_devtools/cli/ci/github_api_error.py,sha256=o_z8VsI706kzmLyzta-flkIvRyosrqpd3-RBqadL32I,712
249
+ agentic_devtools/cli/ci/github_provider.py,sha256=UYCsLabfyuHaqXkG7pB_q0_kpjZt_d3dU3PcaLhku18,501602
248
250
  agentic_devtools/cli/ci/guards.py,sha256=MQbtBWTpwWgNlWPJ1VJL4oZA8XciKs4J1jsN28BQBfQ,59920
249
251
  agentic_devtools/cli/ci/job_logs.py,sha256=YUiW487uO0usSwWD-mpWrs6q7IedQuYyhhBJIc0EAfk,12772
250
252
  agentic_devtools/cli/ci/logging_config.py,sha256=1Ak6hPfqa6BhxhQ96FmpoqqeYk-aJLEdz0ekpb_gEdU,2328
@@ -276,6 +278,7 @@ agentic_devtools/cli/ci/scheduler_command.py,sha256=B8aRt5MycRcMfgEnH3aJ1xiY_EYH
276
278
  agentic_devtools/cli/ci/speckit_trigger.py,sha256=g1FEiWZR89RaN7pDol-HfujbHXArT-hZxb-8eaPOH5Q,909
277
279
  agentic_devtools/cli/ci/supervision_control.py,sha256=N8PJFnQ-nbgzXTsIRzuNh4sXCgZYjK4uAaKgslYHrFs,10878
278
280
  agentic_devtools/cli/ci/supervision_control_command.py,sha256=f0nBl0faEjMNbp3GqTKn3me4sBx9w4egjVR10-FkxJ4,88689
281
+ agentic_devtools/cli/ci/supervision_host_handoff.py,sha256=uBGXWG62tIIFEuKKT-JTokmi14bCLx6xYxYN1v6vQzM,15546
279
282
  agentic_devtools/cli/ci/supervisor.py,sha256=CKFvmMI1zT67wZmqLXsFhjWtVRIyy-QVoP0EqskPlIg,29789
280
283
  agentic_devtools/cli/ci/supervisor_command.py,sha256=DWgIJtsABGFjbVrgoATq1GvDh0dSk5a9FC5tnwnXGMg,10674
281
284
  agentic_devtools/cli/ci/suppressed_reaper.py,sha256=hitRKUr3BhxB5fCJvEDvxMU0_9c6dxxbDWodZHqdX8w,22859
@@ -307,15 +310,15 @@ agentic_devtools/cli/ci/pipeline/diff_validation.py,sha256=LRf-FB2sIap6kLCcqZIw3
307
310
  agentic_devtools/cli/ci/pipeline/exceptions.py,sha256=4UXy2weeQZex_lp4sILNxlYfGjetxbR-tjms2ReT4iw,451
308
311
  agentic_devtools/cli/ci/pipeline/exclusion.py,sha256=mrOG5G1-m3U-GM4YgtMTuzmoj02TXB6GbNxemRoLtV4,931
309
312
  agentic_devtools/cli/ci/pipeline/gate_verdict.py,sha256=-vbp24Kr172wXpB5T-y9lm-7JxiYurixEu3-nuZohgY,44352
310
- agentic_devtools/cli/ci/pipeline/models.py,sha256=Q6b6Z_ItHQZvTefAOB_jPn5N2c5LNEnkgnV8Gp0oWm0,3724
313
+ agentic_devtools/cli/ci/pipeline/models.py,sha256=s-ofx-Y_7lzdLF0KnSkuNUDA0Q0C4vwfy4F8NqMKwFY,4065
311
314
  agentic_devtools/cli/ci/pipeline/repair_attempts.py,sha256=K9JgsxIW0CjxfV6PMmZRKd6w7qsTYClZvzwFSu7shsA,11054
312
315
  agentic_devtools/cli/ci/pipeline/runner.py,sha256=SevvR__WUxZiEr4jXM8OVwYo2YrAIYFHpYXVFgpr_GM,76840
313
316
  agentic_devtools/cli/ci/pipeline/runner_diff_fingerprint.py,sha256=EpasXQX_wUpK3m3jRPTNJCbDU0yDAQQlsg1CynvuKXg,2055
314
317
  agentic_devtools/cli/ci/pipeline/runner_diff_parser.py,sha256=T3ofZoUxKGZaGSyx5dF9yc3ddwRh7tKXWRA7lDaT59Y,17369
315
318
  agentic_devtools/cli/ci/pipeline/session_detector.py,sha256=wVY3RRnsxWJ8duGN4_C2qcIRBWaNRMC8cpf5Ciz6SwM,63864
316
- agentic_devtools/cli/ci/pipeline/snapshot_builder.py,sha256=UCkA4iyknro6C1kYGgK_kY2XJc1ujb9m1yF3Ss60eqY,18599
319
+ agentic_devtools/cli/ci/pipeline/snapshot_builder.py,sha256=WxMSAsfK1h1StwkWAOVHgaorvBpKyjeCAcD1ekGHt8g,20116
317
320
  agentic_devtools/cli/ci/pipeline/snapshot_ci.py,sha256=nvtqRVSEnjWUSCXLEUVa9-JBmMXV08cOLEczogorNTc,4531
318
- agentic_devtools/cli/ci/pipeline/snapshot_models.py,sha256=ZqY8R53xkB1S3ItlgrLRv-sbbaICKWOnP_Zp5eeoevE,12006
321
+ agentic_devtools/cli/ci/pipeline/snapshot_models.py,sha256=NP02aqTG8L1N3v0Wa8DSeaiYGcNHwXaJQBqpwnxCY7E,12349
319
322
  agentic_devtools/cli/ci/pipeline/snapshot_review_comments.py,sha256=GuPKNBJ1g8-nrEcL7QRbMx-lUKwqrBfJlzb2q0z5UeA,7172
320
323
  agentic_devtools/cli/ci/pipeline/snapshot_reviews.py,sha256=wQtzxgw3-0s4m_5flqYYXTu45jLhTRaCkmdclSlPu-c,2837
321
324
  agentic_devtools/cli/ci/pipeline/snapshot_thread_count.py,sha256=QpNI383E3wJyb3jBOkcgvhpxY7cgn9yjyi1DZTlAZBA,7633
@@ -326,7 +329,8 @@ agentic_devtools/cli/ci/pipeline/summary.py,sha256=2xkuMPePCR2DUhnRaEbTVyje92aQl
326
329
  agentic_devtools/cli/ci/pipeline/title_utils.py,sha256=eDkE79WMJdiQ157VEs4NmG0M5FUKtazfDyHXNhMWNfo,708
327
330
  agentic_devtools/cli/ci/pipeline/actions/__init__.py,sha256=sqt2dc9zoLEz5U3GVEBMwbPlFtKnayWmx_E5by_oyrM,1844
328
331
  agentic_devtools/cli/ci/pipeline/actions/apply_suggestions.py,sha256=CIPMgfFSEEQfUnE9bv3tK7mKpVtgVbmcasuqcp4n8xY,24147
329
- agentic_devtools/cli/ci/pipeline/actions/approve.py,sha256=D3oI0g42bOXcEMmTQrR3k99fFqYcxo6AZIu9gLO7Nt8,12304
332
+ agentic_devtools/cli/ci/pipeline/actions/approval_entry.py,sha256=G6nR120RmuOL2lf8Hi8EczER-r94lGWyzXnbCeA-YJY,1911
333
+ agentic_devtools/cli/ci/pipeline/actions/approve.py,sha256=6eKIQyr4rDPoR_sRVzBWfudhAYQV5zsBdQkK74Lsu10,12229
330
334
  agentic_devtools/cli/ci/pipeline/actions/critique_adjudication.py,sha256=y3nsIySF26c9UGqDXL1JrnS_pK2JFuG_aVNrDxEId_w,7301
331
335
  agentic_devtools/cli/ci/pipeline/actions/critique_dispatch.py,sha256=-dp2FHQHNufmonHwypdRYBN4dAomeCUawZrXrz7_414,15843
332
336
  agentic_devtools/cli/ci/pipeline/actions/critique_task_binding.py,sha256=nnZNg14VLuYWh7z4NxMyHX5-VSUQ_0w-cK7sm0ObAek,12658
@@ -1267,11 +1271,11 @@ agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision/SKILL.md,sha256=F
1267
1271
  agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision/exception-merge-policy.md,sha256=kJrT8MpcMg72uODT13-MTVVMBZwwMuhIO-GQjOJozhU,1597
1268
1272
  agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision/extension-guidance.md,sha256=gfAGX4KtQiz7dS_d0iQ6sBOc6Xrg_pmSIt9AJVOFr6A,4550
1269
1273
  agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision/scratch-schema.md,sha256=yj6p6j38-6Vb611PVlNOL__r38d2fLgSvDUhJ_IvpfY,20295
1270
- agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision-admission/SKILL.md,sha256=BNMe12a1r3jgQi15slk81BcLxvjUXujCP6CTO7j7sKw,3059
1274
+ agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision-admission/SKILL.md,sha256=mtax-Xl82zcuZjcLbHqFpd16_xVkEjt0NY3oNXH-fws,3473
1271
1275
  agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision-control/SKILL.md,sha256=kAlJwdILhjTKAEnz9OwbxHJs1kjVt6NDb8z9huPRJFE,4289
1272
1276
  agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision-issue-curator/SKILL.md,sha256=oRPxiiB0pW5_2G1ysr_WlSfTCZnHstzEagfwwuRhvpc,3577
1273
1277
  agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision-maintainer/SKILL.md,sha256=uxg0AEx2udSk-4UVczguqFI33xBJ3Qe6NH3bQyUTyGQ,2459
1274
- agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision-orchestration/SKILL.md,sha256=_5TnjaPv6F_lWBEGjvq_jZ6Q3QLrPHzHN0nGXCK-uEY,4522
1278
+ agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision-orchestration/SKILL.md,sha256=VyEzfnEPCfgT_DDNYnhZPLww3nrRHdZEjOCx584RmmM,5080
1275
1279
  agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision-sso/SKILL.md,sha256=jeRJSReyMy96G-fQK3Nti8gMVzNHYXe8pAnesbNSDMQ,2813
1276
1280
  agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision-steward/SKILL.md,sha256=aMOoBP_KDUOGUM6JdtLAEcZCwfsXkNg2T4f4RlHVFFY,3556
1277
1281
  agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision-worker/SKILL.md,sha256=Sp85L0jMzD2Xlr6gTo5iB-HynBoMx7KLDjgO_-gUBhY,9358
@@ -1289,8 +1293,8 @@ agentic_devtools/_bundled_skills/skills/speckit-plan/SKILL.md,sha256=qYLpzgLCIZ6
1289
1293
  agentic_devtools/_bundled_skills/skills/speckit-specify/SKILL.md,sha256=HbHRbdphMqbtNw6fL6NcYCj6sSFmUyk2S4U0bZ9p_l4,13681
1290
1294
  agentic_devtools/_bundled_skills/skills/write-github-commit-message/SKILL.md,sha256=Ep1lR8NDN117Iytj172VZgqazFiLpJ56Z7RXgaTnxR8,2626
1291
1295
  agentic_devtools/_bundled_skills/skills/write-github-commit-message/commit-types.md,sha256=bTy_t68ZD6R87rFIzq9wybTu8Wudoahb7nB9IWC4bFo,1839
1292
- agentic_devtools-0.2.577.dist-info/METADATA,sha256=UDLYuI2Tr9N_i5CYmG3b1TL9uHo8cntMfnpr8BWPO1E,35393
1293
- agentic_devtools-0.2.577.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
1294
- agentic_devtools-0.2.577.dist-info/entry_points.txt,sha256=kL35_Y5OHI6XpPneeBTcX82ORjjfx2VIK6XfmKHgNm4,13630
1295
- agentic_devtools-0.2.577.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
1296
- agentic_devtools-0.2.577.dist-info/RECORD,,
1296
+ agentic_devtools-0.2.579.dist-info/METADATA,sha256=69TNmXx21RVJB_e4Wyc5ZXAg-E0hZzAq0xDI8RKbyvI,35393
1297
+ agentic_devtools-0.2.579.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
1298
+ agentic_devtools-0.2.579.dist-info/entry_points.txt,sha256=kL35_Y5OHI6XpPneeBTcX82ORjjfx2VIK6XfmKHgNm4,13630
1299
+ agentic_devtools-0.2.579.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
1300
+ agentic_devtools-0.2.579.dist-info/RECORD,,