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.
- agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision-admission/SKILL.md +11 -6
- agentic_devtools/_bundled_skills/skills/ai-pr-loop-supervision-orchestration/SKILL.md +12 -7
- agentic_devtools/_version.py +2 -2
- agentic_devtools/cli/ci/approver_preflight.py +145 -0
- agentic_devtools/cli/ci/github_api_error.py +20 -0
- agentic_devtools/cli/ci/github_provider.py +21 -20
- agentic_devtools/cli/ci/pipeline/actions/approval_entry.py +44 -0
- agentic_devtools/cli/ci/pipeline/actions/approve.py +27 -24
- agentic_devtools/cli/ci/pipeline/models.py +6 -0
- agentic_devtools/cli/ci/pipeline/snapshot_builder.py +35 -3
- agentic_devtools/cli/ci/pipeline/snapshot_models.py +4 -0
- agentic_devtools/cli/ci/supervision_host_handoff.py +358 -0
- {agentic_devtools-0.2.577.dist-info → agentic_devtools-0.2.579.dist-info}/METADATA +1 -1
- {agentic_devtools-0.2.577.dist-info → agentic_devtools-0.2.579.dist-info}/RECORD +17 -13
- {agentic_devtools-0.2.577.dist-info → agentic_devtools-0.2.579.dist-info}/WHEEL +0 -0
- {agentic_devtools-0.2.577.dist-info → agentic_devtools-0.2.579.dist-info}/entry_points.txt +0 -0
- {agentic_devtools-0.2.577.dist-info → agentic_devtools-0.2.579.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
agentic_devtools/_version.py
CHANGED
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
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
|
|
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
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
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
|
-
|
|
4241
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
56
|
-
if
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
116
|
-
#
|
|
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
|
-
|
|
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,5 +1,5 @@
|
|
|
1
1
|
agentic_devtools/__init__.py,sha256=J_Zw_vWKghk-cLmqI83hXQmSiS8zMhGIHM5WPLDkZuo,242
|
|
2
|
-
agentic_devtools/_version.py,sha256=
|
|
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/
|
|
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=
|
|
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=
|
|
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=
|
|
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/
|
|
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=
|
|
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=
|
|
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.
|
|
1293
|
-
agentic_devtools-0.2.
|
|
1294
|
-
agentic_devtools-0.2.
|
|
1295
|
-
agentic_devtools-0.2.
|
|
1296
|
-
agentic_devtools-0.2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|