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
4 changes: 3 additions & 1 deletion .claude/agents/lane.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ Before publishing the PR as non-draft, render the versioned embedded carrier
with `devtools workspace pr-scope render --input <scope.json>`, put that exact
comment beside the human matrix, and validate the published PR with
`devtools workspace pr-scope check --pr <N>`. Never infer a disposition from
Bead acceptance prose or invent a successor ID.
Bead acceptance prose or invent a successor ID. The v2 body is stable intent;
use `devtools workspace pr-scope sync --pr <N>` to inspect the current
head-bound attestation rather than rewriting the body after every commit.

Reference any bead with neutral wording only (`Ref polylogue-xxxx` /
`Ref #N`). **Never use GitHub resolver keywords** (closes/fixes/resolves)
Expand Down
12 changes: 7 additions & 5 deletions .codex/agents/reviewer.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ Lead with concrete findings, most severe first, each with a source anchor
and a reproduction command where applicable. State explicitly whether you
consider the change safe to merge. Do not edit the branch.

For every non-draft PR, inspect the human per-Bead disposition matrix and run
`devtools workspace pr-scope check --pr <N>`. Reject a missing carrier, a
carrier not bound to the current head, stale Bead records, a partial outcome
without an open named successor, or evidence that does not support the stated
whole-Bead disposition. Do not treat Bead acceptance prose as machine input.
For every non-draft PR, inspect the human per-Bead disposition matrix, run
`devtools workspace pr-scope check --pr <N>`, and run
`devtools workspace pr-scope sync --pr <N>`. Reject a missing carrier, a
missing or invalid sync-produced current-head attestation, stale Bead records,
a partial outcome without an open named successor, or evidence that does not
support the stated whole-Bead disposition. Do not treat Bead acceptance prose
as machine input.
"""
14 changes: 9 additions & 5 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,18 @@ _Exact commands run and any manual validation performed._
| --- | --- | --- | --- |
| `polylogue-...` | satisfied / partial / deferred / superseded | `test:...`, `command:...` | `polylogue-...` or n/a |

<!-- polylogue-pr-scope:v1
<!-- polylogue-pr-scope:v2
Replace this comment with the output of:
devtools workspace pr-scope render --input .agent/pr-scope.json > /tmp/pr-scope.md

The input declares assigned_beads and one disposition with typed evidence for
each. Partial, deferred, and superseded dispositions require an existing open
successor Bead. Copy the rendered comment here after the final push, then run:
devtools workspace pr-scope check --pr <PR-number>
The stable intent input declares scope_kind, assigned_beads, mutated_beads, and
one disposition with typed evidence for each assigned Bead. Use
scope_kind=self_contained with empty Bead lists for a self-contained PR.
mutated_beads declares every Bead record changed by this PR. Partial, deferred,
and superseded dispositions require an existing open successor Bead. The body
does not contain a head SHA or Bead digest. After each push, inspect the live
attestation with:
devtools workspace pr-scope sync --pr <PR-number>
-->

## Changelog
Expand Down
16 changes: 9 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,12 +381,14 @@ workflow, not optional conveniences — use them at the point named, every time:
- **Before opening a non-draft PR for a Bead lane**: render the versioned
carrier with `devtools workspace pr-scope render --input <scope.json>`, put
it in the PR body beside the human whole-Bead disposition matrix, then run
`devtools workspace pr-scope check --pr <PR>`. The carrier binds the exact
head SHA, canonical Bead records, typed dispositions, evidence refs, and
open successors for residual work; it never parses acceptance prose. After
the final commit is created, regenerate the carrier for that exact SHA and
update the PR body before pushing; CircleCI does not rerun for a body-only
edit.
`devtools workspace pr-scope check --pr <PR>`. The v2 carrier is stable PR
intent: scope kind, assigned and mutated Beads, typed dispositions, evidence
refs, and open successors for residual work. It never parses acceptance
prose. `devtools workspace pr-scope sync --pr <PR>` reports the current
head-bound attestation, including canonical Bead state, without editing the
PR body after every commit. `mutated_beads` must name every Bead record this
PR changes. A self-contained PR uses the typed `self_contained` scope with
empty Bead lists.
CircleCI uses `pr-scope check-ci`, resolves PR metadata through public GitHub
REST when `CIRCLE_PULL_REQUEST` is absent, and executes the validator from
the PR base revision so a PR cannot weaken its own scope gate.
Expand All @@ -404,7 +406,7 @@ workflow, not optional conveniences — use them at the point named, every time:
then auto-records a receipt if none is fresh for the current head
sha (running `--command`, default `devtools verify`), BLOCKs the merge on
any `merge-gate check` failure (no fresh receipt, stale receipt, nonzero
exit, a changed carrier digest, or an unacked review comment newer than the head commit), strips a
exit, a changed head-bound scope attestation, or an unacked review comment newer than the head commit), strips a
doubled `(#N) (#N)` squash-subject suffix, then runs the actual
`gh pr merge --squash`. `--dry-run` runs every check without merging;
`--with-verify` immediately runs and records the merge-train's terminal
Expand Down
15 changes: 8 additions & 7 deletions devtools/command_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,19 +774,20 @@ class CatalogBypassSite:
CommandSpec(
"workspace pr-scope",
"workspace",
"Render and validate the versioned PR Bead-scope carrier.",
"Render stable PR scope intent and inspect its mutable merge attestation.",
"devtools.pr_scope",
use_when=(
"Before publishing a non-draft PR, render the machine-readable carrier from the assigned "
"Bead dispositions, then validate the exact body against its head SHA and committed Bead records. "
"CircleCI quick-gate and `workspace merge` run the same validator; the command never interprets "
"acceptance prose."
"Before publishing a non-draft PR, render the machine-readable v2 intent from assigned and mutated "
"Beads plus typed dispositions. The PR body stays stable across commits; `sync --pr` reports the "
"current head- and Bead-bound attestation consumed by merge-gate. CircleCI quick-gate and "
"`workspace merge` run the same validator; the command never interprets acceptance prose."
),
examples=(
"devtools workspace pr-scope render --input .agent/pr-scope.json > /tmp/pr-scope.md",
"devtools workspace pr-scope check --pr 3517",
"devtools workspace pr-scope sync --pr 3517",
"devtools workspace pr-scope check-ci --pr 3517 --repo Sinity/polylogue --expected-head-sha $(git rev-parse HEAD)",
"devtools workspace pr-scope check --body-file pr-body.md --head-sha $(git rev-parse HEAD)",
"devtools workspace pr-scope check --body-file pr-body.md --head-sha $(git rev-parse HEAD) --base-sha $(git rev-parse origin/master)",
),
),
CommandSpec(
Expand All @@ -805,7 +806,7 @@ class CatalogBypassSite:
"3x20s, covering CodeRabbit's 30-60s late-arrival window) and BLOCKs unless a receipt exists "
"for the CURRENT head sha within a freshness window with exit_code 0, and no review comment's "
"created_at is newer than the head commit's timestamp unless explicitly `ack`'d for that exact "
"head sha. The receipt binds the carrier digest, so a changed scope requires re-recording. Motivated by two 2026-08-01 incidents: PR #3502 merged before CodeRabbit's findings "
"head sha. The receipt binds the carrier digest plus a fresh head- and Bead-bound scope attestation, so a changed scope or Bead state requires re-recording. Motivated by two 2026-08-01 incidents: PR #3502 merged before CodeRabbit's findings "
"posted, and PR #3517 nearly merged with a 43-test regression no CI check or review comment "
"ever flagged -- plus review findings on this tool itself (recording from an unrelated "
"checkout, a --quick example that would have missed its own motivating regression, a single "
Expand Down
4 changes: 3 additions & 1 deletion devtools/lane_brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,12 @@
)

_PR_SCOPE_CARRIER = (
"Before opening a non-draft PR, render the structured carrier from assigned Bead IDs, "
"Before opening a non-draft PR, render stable v2 intent from assigned and mutated Bead IDs, "
"whole-Bead dispositions, typed evidence refs, and open successors for residual work: "
"`devtools workspace pr-scope render --input .agent/pr-scope.json`. Embed the rendered comment "
"in the PR body and validate the published PR with `devtools workspace pr-scope check --pr <N>`. "
"Use `devtools workspace pr-scope sync --pr <N>` to inspect the current head-bound attestation without "
"rewriting the body after each commit. "
"Do not infer acceptance from Bead prose or invent missing Bead IDs."
)

Expand Down
80 changes: 76 additions & 4 deletions devtools/merge_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
from pathlib import Path
from typing import Any

from devtools import merge_gate
from devtools import merge_gate, pr_scope
from devtools.testmon_state import TerminalAuthorization, VerificationScope

_LEDGER_PATH = Path(".cache/verify/merge-gate/merge-train-ledger.json")
Expand Down Expand Up @@ -418,12 +418,35 @@ def _pending_prs_since_last_full_verify(ledger: dict[str, Any]) -> list[dict[str
]


def _receipt_is_fresh_for_head(pr: int, head_sha: str, max_age_s: int) -> bool:
def _receipt_is_fresh_for_scope(
pr: int,
*,
head_sha: str,
scope: pr_scope.ScopeVerdict,
base_sha: str | None,
max_age_s: int,
) -> bool:
receipt = merge_gate._read_json_object(merge_gate._receipt_path(pr))
if receipt is None:
return False
if receipt.get("head_sha") != head_sha:
return False
expected_attestation = pr_scope.attestation_payload(scope, head_sha=head_sha, base_sha=base_sha)[
"attestation_digest"
]
if receipt.get("pr_scope_attestation_digest") != expected_attestation:
return False
if receipt.get("exit_code") != 0:
return False
verification_scope = receipt.get("verification_scope")
valid_scopes = {scope.value for scope in VerificationScope}
if verification_scope not in valid_scopes:
return False
release_allowed = receipt.get("release_baseline_allowed")
if not isinstance(release_allowed, bool):
return False
if verification_scope == VerificationScope.RELEASE_BASELINE.value and not release_allowed:
return False
age_s = time.time() - float(receipt.get("recorded_at", 0))
return bool(age_s <= max_age_s)

Expand Down Expand Up @@ -567,7 +590,15 @@ def cmd_merge(
verify_command: str,
) -> int:
try:
info = _gh_json(["pr", "view", str(pr), "--json", "headRefOid,title,state"])
info = _gh_json(
[
"pr",
"view",
str(pr),
"--json",
"headRefOid,baseRefOid,title,state,body,isDraft,author,files",
]
)
except (RuntimeError, json.JSONDecodeError, OSError, subprocess.SubprocessError) as exc:
print(f"REFUSING to merge PR #{pr}: gh pr view failed: {exc}", file=sys.stderr)
return 1
Expand All @@ -577,8 +608,21 @@ def cmd_merge(
return 1

head_sha = info["headRefOid"]
scope = merge_gate._scope_verdict(pr, info, head_sha=head_sha)

if not _receipt_is_fresh_for_head(pr, head_sha, max_age_s):
if not scope.ok:
print(f"REFUSING to merge PR #{pr}: invalid structured pr-scope carrier:", file=sys.stderr)
for reason in scope.reasons:
print(f" - {reason}", file=sys.stderr)
return 2

if not _receipt_is_fresh_for_scope(
pr,
head_sha=head_sha,
scope=scope,
base_sha=merge_gate._base_sha(info),
Comment thread
Sinity marked this conversation as resolved.
max_age_s=max_age_s,
):
print(
f"no fresh merge-gate receipt for PR #{pr} @ {head_sha[:8]} -- recording one now via {command!r}",
file=sys.stderr,
Expand All @@ -605,6 +649,34 @@ def cmd_merge(
print(f"PR #{pr} @ {head_sha[:8]}: merge-gate OK -- dry-run, not merging (title would be {clean_title!r})")
return 0

try:
final_info = _gh_json(
["pr", "view", str(pr), "--json", "headRefOid,baseRefOid,state,body,isDraft,author,files"]
)
except (RuntimeError, json.JSONDecodeError, OSError, subprocess.SubprocessError) as exc:
print(f"REFUSING to merge PR #{pr}: final authority check failed: {exc}", file=sys.stderr)
return 1
if (
final_info.get("state") != "OPEN"
or final_info.get("headRefOid") != head_sha
or merge_gate._base_sha(final_info) != merge_gate._base_sha(info)
):
print(
f"REFUSING to merge PR #{pr}: head, base, or state changed after merge-gate validation",
file=sys.stderr,
)
return 1
final_scope = merge_gate._scope_verdict(pr, final_info, head_sha=head_sha)
initial_attestation = pr_scope.attestation_payload(
scope, head_sha=head_sha, base_sha=merge_gate._base_sha(info)
).get("attestation_digest")
final_attestation = pr_scope.attestation_payload(
final_scope, head_sha=head_sha, base_sha=merge_gate._base_sha(final_info)
).get("attestation_digest")
if not final_scope.ok or final_attestation != initial_attestation:
print(f"REFUSING to merge PR #{pr}: structured scope changed after merge-gate validation", file=sys.stderr)
return 1

try:
_record_merge_intent(pr, head_sha, clean_title)
except LedgerStateError as exc:
Expand Down
45 changes: 43 additions & 2 deletions devtools/merge_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,12 @@ def _terminal_authorization(stdout: str) -> str | None:
return value if value in {authorization.value for authorization in TerminalAuthorization} else None


def _base_sha(info: dict[str, Any]) -> str | None:
"""Read the PR base commit SHA when GitHub reported one."""
value = info.get("baseRefOid")
return value if isinstance(value, str) else None


def _scope_verdict(pr: int, info: dict[str, Any], *, head_sha: str) -> pr_scope.ScopeVerdict:
"""Use the same carrier or typed bot exception for record and check."""
author = info.get("author")
Expand All @@ -278,11 +284,12 @@ def _scope_verdict(pr: int, info: dict[str, Any], *, head_sha: str) -> pr_scope.
info.get("body") or "",
head_sha=head_sha,
is_draft=bool(info.get("isDraft")),
base_sha=_base_sha(info),
)


def cmd_record(pr: int, command: str) -> int:
info = _gh_json(["pr", "view", str(pr), "--json", "headRefOid,headRefName,body,isDraft,author,files"])
info = _gh_json(["pr", "view", str(pr), "--json", "headRefOid,headRefName,baseRefOid,body,isDraft,author,files"])
head_sha = info["headRefOid"]

local_head = _git_head_sha()
Expand Down Expand Up @@ -328,6 +335,12 @@ def cmd_record(pr: int, command: str) -> int:
"pr_scope_digest": scope.scope_digest,
"pr_scope_beads_digest": scope.beads_digest,
"pr_scope_assigned_beads": scope.assigned_beads,
"pr_scope_mutated_beads": scope.mutated_beads,
"pr_scope_attestation_digest": pr_scope.attestation_payload(
scope,
head_sha=head_sha,
base_sha=_base_sha(info),
)["attestation_digest"],
"branch": info["headRefName"],
"command": command,
"skips_tests": _command_skips_tests(command),
Expand Down Expand Up @@ -459,7 +472,7 @@ def cmd_check(
"view",
str(pr),
"--json",
"headRefOid,mergeStateStatus,state,commits,body,isDraft,author,files",
"headRefOid,baseRefOid,mergeStateStatus,state,commits,body,isDraft,author,files",
]
)
except (RuntimeError, json.JSONDecodeError, OSError, subprocess.SubprocessError) as exc:
Expand All @@ -472,6 +485,19 @@ def cmd_check(
head_sha = info["headRefOid"]
verdict.head_sha = head_sha

local_head = _git_head_sha()
if local_head != head_sha:
verdict.ok = False
verdict.reasons.append(
f"current checkout HEAD ({local_head[:8] if local_head else '?'}) does not match PR #{pr}'s "
f"head ({head_sha[:8]}); check out the exact PR commit before checking"
)
if not _git_is_clean():
verdict.ok = False
verdict.reasons.append(
"current checkout has uncommitted changes; merge-gate check requires committed PR content"
)

scope = _scope_verdict(pr, info, head_sha=head_sha)
verdict.pr_scope = asdict(scope)
if not scope.ok:
Expand Down Expand Up @@ -529,6 +555,21 @@ def cmd_check(
verdict.reasons.append(
"receipt pr_scope_assigned_beads does not match the current carrier -- re-record"
)
if receipt.get("pr_scope_mutated_beads") != scope.mutated_beads:
verdict.ok = False
verdict.reasons.append(
"receipt pr_scope_mutated_beads does not match the complete current Bead mutation scope -- re-record"
)
expected_attestation_digest = pr_scope.attestation_payload(
scope,
head_sha=head_sha,
base_sha=_base_sha(info),
)["attestation_digest"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if receipt.get("pr_scope_attestation_digest") != expected_attestation_digest:
Comment thread
Sinity marked this conversation as resolved.
verdict.ok = False
verdict.reasons.append(
"receipt pr_scope_attestation_digest does not match the current head-bound scope attestation -- re-record"
)
age_s = time.time() - receipt.get("recorded_at", 0)
if age_s > max_age_s:
verdict.ok = False
Expand Down
Loading