Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ merged commit and correcting the rule that let it through.
- the change is single-purpose and easy to review from the diff;
- required checks or focused smokes have passed;
- the exact head carries a published self-review, and
`loopx pr-review --check-merge-readiness NUMBER@HEAD_OID` returned ready for
`loopx pr-review --goal-id GOAL --check-merge-readiness NUMBER@HEAD_OID`
returned ready for
that unchanged head. GitHub blocks formal self-approval, so on an
author-owned PR the record is a `COMMENTED` review on the exact head that
states the approval conclusion and an English verdict; a green CI run, a diff
Expand Down
81 changes: 81 additions & 0 deletions examples/pr-review-command-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,33 @@ def assert_public_safe(payload: dict[str, object]) -> None:
)


# `--check-merge-readiness` now mandates a Goal id. Every authoritative
# invocation an agent may read has to carry it, or the canonical self-merge
# gate fails deterministically before it can record its observation.
MERGE_READINESS_GUIDANCE_PATHS = (
REPO_ROOT / "AGENTS.md",
REPO_ROOT / "loopx" / "capabilities" / "pr_review_queue" / "README.md",
REPO_ROOT / "loopx" / "capabilities" / "pr_review_queue" / "catalog_entry.py",
PR_REVIEW_SKILL,
PR_MERGE_SKILL,
)


def assert_merge_readiness_invocations_require_goal_id() -> None:
for path in MERGE_READINESS_GUIDANCE_PATHS:
source = path.read_text(encoding="utf-8")
for span in re.findall(r"`[^`]*--check-merge-readiness[^`]*`", source):
assert "--goal-id" in span, (
f"{path.name} must pass --goal-id to --check-merge-readiness: {span}"
)
for line in source.splitlines():
if "--check-merge-readiness" in line and "`" not in line:
assert "--goal-id" in line, (
f"{path.name} must pass --goal-id to --check-merge-readiness: "
f"{line.strip()}"
)


def main() -> int:
skill_source = PR_REVIEW_SKILL.read_text(encoding="utf-8")
skill_text = " ".join(skill_source.split())
Expand Down Expand Up @@ -149,6 +176,7 @@ def main() -> int:
"A merge decision without this evidence is not authorized",
):
assert phrase in merge_text, phrase
assert_merge_readiness_invocations_require_goal_id()

assert _github_search_date("2026-06-28T00:00:00+08:00") == "2026-06-27"
assert _github_search_date("2026-06-28T00:00:00Z") == "2026-06-28"
Expand Down Expand Up @@ -316,6 +344,12 @@ def fake_run_gh_json(args: list[str], *, cwd: Path | None = None) -> object:

merge_head = "e" * 40
with tempfile.TemporaryDirectory() as temp_dir:
runtime_root = Path(temp_dir) / "runtime"
registry_path = Path(temp_dir) / "registry.json"
registry_path.write_text(
json.dumps({"goals": [{"id": "test-goal", "repo": temp_dir}]}),
encoding="utf-8",
)
merge_fixture_path = Path(temp_dir) / "merge-readiness.json"
merge_fixture = {
"repository": "owner/repo",
Expand Down Expand Up @@ -372,9 +406,15 @@ def fake_run_gh_json(args: list[str], *, cwd: Path | None = None) -> object:
merge_fixture_path.write_text(json.dumps(merge_fixture), encoding="utf-8")
ready = json.loads(
run_cli(
"--runtime-root",
str(runtime_root),
"--registry",
str(registry_path),
"--format",
"json",
"pr-review",
"--goal-id",
"test-goal",
"--fixture",
str(merge_fixture_path),
"--check-merge-readiness",
Expand All @@ -383,6 +423,29 @@ def fake_run_gh_json(args: list[str], *, cwd: Path | None = None) -> object:
)
assert ready["ready"] is True, ready
assert ready["blocking_reasons"] == [], ready
unchanged_queue = json.loads(
run_cli(
"--runtime-root",
str(runtime_root),
"--registry",
str(registry_path),
"--format",
"json",
"pr-review",
"--goal-id",
"test-goal",
"--fixture",
str(merge_fixture_path),
"--state",
"open",
).stdout
)
unchanged_item = unchanged_queue["pull_requests"][0]
assert unchanged_item["review_action_kind"] is None, unchanged_item
assert (
unchanged_item["merge_readiness_observation"]["observation_state"]
== "observed_unchanged"
), unchanged_item

merge_fixture["pull_requests"][0]["reviews"][0]["body"] = merge_fixture[
"pull_requests"
Expand All @@ -392,9 +455,15 @@ def fake_run_gh_json(args: list[str], *, cwd: Path | None = None) -> object:
)
merge_fixture_path.write_text(json.dumps(merge_fixture), encoding="utf-8")
blocked_run = run_cli(
"--runtime-root",
str(runtime_root),
"--registry",
str(registry_path),
"--format",
"json",
"pr-review",
"--goal-id",
"test-goal",
"--fixture",
str(merge_fixture_path),
"--check-merge-readiness",
Expand Down Expand Up @@ -471,6 +540,12 @@ def approved_open_head(
}

with tempfile.TemporaryDirectory() as temp_dir:
runtime_root = Path(temp_dir) / "runtime"
registry_path = Path(temp_dir) / "registry.json"
registry_path.write_text(
json.dumps({"goals": [{"id": "test-goal", "repo": temp_dir}]}),
encoding="utf-8",
)
approval_fixture_path = Path(temp_dir) / "approved-open-heads.json"
approval_fixture = {
"repository": "owner/repo",
Expand Down Expand Up @@ -522,9 +597,15 @@ def approved_open_head(
):
readiness = json.loads(
run_cli(
"--runtime-root",
str(runtime_root),
"--registry",
str(registry_path),
"--format",
"json",
"pr-review",
"--goal-id",
"test-goal",
"--fixture",
str(approval_fixture_path),
"--check-merge-readiness",
Expand Down
38 changes: 26 additions & 12 deletions loopx/capabilities/pr_review_queue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ workflow or the merge-focused `loopx-pr-merge` skill.
| Command | CLI reference | Intent |
| --- | --- | --- |
| `/loopx-pr-review` | `loopx pr-review [--repo owner/repo] [--target-exact-head NUMBER@HEAD_OID] [--state open\|merged\|all] [--review-priority other-developers-first\|owner-first] [--since ISO] [--fresh-audit-exact-head NUMBER@HEAD_OID]` | Review a small explicit batch with repeatable `--target-exact-head`, or list a lifecycle queue when no target is supplied. Both paths provide concrete main-regression analysis and the five-block review contract. The default queue prioritizes non-owner developer PRs; `owner-first` opts into owner priority. `--fresh-audit-exact-head` separately forces new evidence for an unchanged concluded head. |
| pre-merge readback | `loopx pr-review --repo owner/repo --check-merge-readiness NUMBER@HEAD_OID` | Immediately before merge, fail closed unless the remote PR is still open at the reviewed head, its standalone conclusion approves that head, all checks are successful or skipped, review-thread pagination is complete with no unresolved thread, and merge state is compatible. This read grants no merge authority. |
| pre-merge readback | `loopx pr-review --goal-id GOAL --repo owner/repo --check-merge-readiness NUMBER@HEAD_OID` | Immediately before merge, fail closed unless the remote PR is still open at the reviewed head, its standalone conclusion approves that head, all checks are successful or skipped, review-thread pagination is complete with no unresolved thread, and merge state is compatible. The Goal-scoped command records a compact public-safe readiness observation; this read grants no merge authority. |

The slash command must run the CLI first. Agentloop must not reconstruct the
review window by manually calling `gh pr view` / `gh pr list` for every PR. The
Expand Down Expand Up @@ -232,15 +232,18 @@ contain only rows whose `review_action_kind` is non-null. A merged exact head
without a valid conclusion receives `audit_merged_pull_request_exact_head`; a
merged or open exact head whose valid conclusion is not an approval remains
inventory-only and cannot become the recommended first PR. An open exact head
with a valid approval keeps owing `qualify_pull_request_merge_readiness`,
because merge readiness is decided by the typed verdict rather than by GitHub's
with a valid approval owes `qualify_pull_request_merge_readiness` until the Goal
has observed its current readiness material state. An unchanged observation
suppresses duplicate qualification work; exact-head, base, review conclusion,
configured CI, review-thread, merge-state, or draft-state changes reopen it.
This preserves the typed readiness verdict rather than relying on GitHub's
review state: the platform blocks self-approval, so an author-owned approval is
recorded as `COMMENTED` and a state-based rule would count that still-unmerged
head as concluded, even after it goes behind, conflicts, loses its checks or is
blocked. The summary's attention counts are derived from this same actionable
set. Inventory-only rows set `review_plan` and `review_template` to null and
`evidence_commands` to an empty list so hosts cannot mistake readback metadata
for execution authority.
recorded as `COMMENTED` and a state-based rule could otherwise count a
still-unmerged head as concluded even after it goes behind, conflicts, loses
its checks or is blocked. The summary's attention counts are derived from this
same actionable set. Inventory-only rows set `review_plan` and
`review_template` to null and `evidence_commands` to an empty list so hosts
cannot mistake readback metadata for execution authority.

It emits a
`pull_request_review_todo_preview_v0` bound to its exact head. The preview may
Expand Down Expand Up @@ -526,6 +529,15 @@ bypass may satisfy GitHub's author-owned
self-review limitation, but it never overrides this capability gate or supplies
user merge authority.

`pull_request_merge_readiness_observation_v0` is the Goal-scoped scheduling
receipt for that gate. It persists only the exact-head material fingerprint and
compact public-safe readiness result under the local Goal runtime. It excludes
review bodies, raw logs, credentials, private payloads, and local paths. Queue
construction consumes the observation only when every readiness input still
matches; a changed head, base, review conclusion, CI policy/result, review
thread, draft flag, merge state, or PR state fails open to a fresh
qualification.

They must not include raw logs, private connector payloads, credentials, local
absolute paths, private source bodies, or hidden CI artifacts.

Expand Down Expand Up @@ -829,7 +841,8 @@ The packet should let a reviewer move through PRs in order:
copied as the final risk judgement.
8. Recheck the exact head, then decide `approve`, `request changes`, `defer`, or
`merge after checks`. Immediately before merge, require
`--check-merge-readiness NUMBER@HEAD_OID` to return `ready=true`.
`loopx pr-review --goal-id GOAL --check-merge-readiness NUMBER@HEAD_OID` to
return `ready=true`.

A response that only lists `Open` and `Merged` PRs, scale, and recommended next
order is incomplete for `/loopx-pr-review`; it should continue into the
Expand Down Expand Up @@ -859,9 +872,10 @@ A first implementation is acceptable when:
- `--fresh-audit-exact-head NUMBER@HEAD_OID` is the only packet-level way to
turn an unchanged valid conclusion into an actionable fresh audit, and
malformed, absent, or already-actionable targets fail closed;
- `--check-merge-readiness NUMBER@HEAD_OID` rejects head drift, stale review
- Goal-scoped `--goal-id GOAL --check-merge-readiness NUMBER@HEAD_OID` rejects head drift, stale review
prose, non-approval conclusions, red/pending/unknown checks, incomplete or
unresolved review-thread evidence, and incompatible merge state;
unresolved review-thread evidence, and incompatible merge state, then records
the compact observation used to suppress only unchanged requalification;
- the default limit is 100, and exhaustive requests only proceed when
`result_completeness.complete=true`; truncated packets provide a larger
`recommended_limit` for the next read;
Expand Down
12 changes: 9 additions & 3 deletions loopx/capabilities/pr_review_queue/catalog_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@
),
"commands": [
{
"command": "loopx pr-review --repo <owner/repo> --check-merge-readiness NUMBER@HEAD_OID --format json",
"command": "loopx pr-review --goal-id <goal-id> --repo <owner/repo> --check-merge-readiness NUMBER@HEAD_OID --format json",
"purpose": "Fail closed on exact-head, approval-body, configured CI, thread, or merge-state drift immediately before merge.",
"write_boundary": "live public GitHub read only; does not approve, merge, bypass policy, or grant merge authority",
"write_boundary": "reads live public GitHub state and writes one compact public-safe Goal observation; does not approve, merge, bypass policy, or grant merge authority",
},
{
"command": "loopx pr-review --check-result <result.json> --packet <packet.json> --format json",
Expand Down Expand Up @@ -82,6 +82,11 @@
"module": "loopx.capabilities.pr_review_queue.merge_readiness",
"doc": "loopx/capabilities/pr_review_queue/README.md",
},
{
"schema_version": "pull_request_merge_readiness_observation_v0",
"module": "loopx.capabilities.pr_review_queue.readiness_observation",
"doc": "loopx/capabilities/pr_review_queue/README.md",
},
{
"schema_version": "pull_request_review_result_check_v0",
"module": "loopx.capabilities.pr_review_queue.result_check",
Expand Down Expand Up @@ -151,7 +156,8 @@
"Only rows with a non-null review_action_kind enter review_sequence and carry review plans, templates, or evidence commands; valid exact-head conclusions remain artifact-free inventory-only rows, and only --fresh-audit-exact-head NUMBER@HEAD_OID can explicitly reopen one.",
"Todo prose, monitor notes, and one-off author filters are not scheduling authority.",
"A complete exact-head conclusion requires the five Chinese sections, a state-aligned English verdict, and formal state or the verdict-specific titled author-owned fallback.",
"Every merge must rerun the read-only merge-readiness gate for the reviewed exact head; admin bypass cannot override stale review text, required CI when wait_for_ci is true, incomplete thread evidence, or head drift.",
"Every merge must rerun the Goal-scoped merge-readiness gate for the reviewed exact head; the resulting compact observation suppresses only unchanged qualification work, and any head, base, review, CI, thread, draft, merge-state, or PR-state change reopens it.",
"Readiness observations contain only public-safe material fingerprints and compact verdicts; they exclude review bodies, raw logs, credentials, private payloads, and local paths.",
"One observation emits at most one exact-head advancement Todo preview; unchanged observations replay it until explicit durable Todo-projection ACK, then rotate across acknowledged exact heads.",
"The capability reuses the existing pr-review GitHub scan and normalized packet; review bodies are inspected for format but never emitted or checkpointed.",
"Candidate selection grants no GitHub review, comment, push, merge, quota, or Todo-write authority; those remain with their existing policy surfaces.",
Expand Down
1 change: 1 addition & 0 deletions loopx/capabilities/pr_review_queue/github_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"headRefName",
"headRefOid",
"baseRefName",
"baseRefOid",
"author",
"createdAt",
"updatedAt",
Expand Down
13 changes: 13 additions & 0 deletions loopx/capabilities/pr_review_queue/merge_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
from collections.abc import Mapping
from typing import Any

from .readiness_observation import (
readiness_material_fingerprint,
readiness_material_state,
)


SCHEMA_VERSION = "pull_request_merge_readiness_v0"

Expand Down Expand Up @@ -89,6 +94,12 @@ def build_merge_readiness(
blockers.append("repository_merge_state_blocked")

blockers = list(dict.fromkeys(blockers))
material_state = readiness_material_state(
repository=repository,
item=item,
review_threads=review_threads,
wait_for_ci=wait_for_ci,
)
return {
"ok": True,
"schema_version": SCHEMA_VERSION,
Expand All @@ -111,6 +122,8 @@ def build_merge_readiness(
"ci_policy": "required" if wait_for_ci else "not_consulted",
"wait_for_ci": wait_for_ci,
"blocking_reasons": blockers,
"material_state": material_state,
"material_fingerprint": readiness_material_fingerprint(material_state),
"authority": {
"grants_merge_authority": False,
"admin_bypass_overrides_this_gate": False,
Expand Down
Loading
Loading