diff --git a/.claude/agents/lane.md b/.claude/agents/lane.md index 7d1b29ce1f..101deb4ae9 100644 --- a/.claude/agents/lane.md +++ b/.claude/agents/lane.md @@ -117,7 +117,9 @@ Before publishing the PR as non-draft, render the versioned embedded carrier with `devtools workspace pr-scope render --input `, put that exact comment beside the human matrix, and validate the published PR with `devtools workspace pr-scope check --pr `. 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 ` 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) diff --git a/.codex/agents/reviewer.toml b/.codex/agents/reviewer.toml index 9b16e67e95..ef7b44523b 100644 --- a/.codex/agents/reviewer.toml +++ b/.codex/agents/reviewer.toml @@ -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 `. 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 `, and run +`devtools workspace pr-scope sync --pr `. 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. """ diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 263e6ae83a..ba3b626d42 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -24,14 +24,18 @@ _Exact commands run and any manual validation performed._ | --- | --- | --- | --- | | `polylogue-...` | satisfied / partial / deferred / superseded | `test:...`, `command:...` | `polylogue-...` or n/a | - ## Changelog diff --git a/CLAUDE.md b/CLAUDE.md index bf23ef99cd..2f412e8de8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `, put it in the PR body beside the human whole-Bead disposition matrix, then run - `devtools workspace pr-scope check --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 `. 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 ` 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. @@ -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 diff --git a/devtools/command_catalog.py b/devtools/command_catalog.py index 207b2a50c1..f6d46cf21e 100644 --- a/devtools/command_catalog.py +++ b/devtools/command_catalog.py @@ -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( @@ -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 " diff --git a/devtools/lane_brief.py b/devtools/lane_brief.py index e619597d35..f1f3d4ec0a 100644 --- a/devtools/lane_brief.py +++ b/devtools/lane_brief.py @@ -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 `. " + "Use `devtools workspace pr-scope sync --pr ` 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." ) diff --git a/devtools/merge_boundary.py b/devtools/merge_boundary.py index f97f8a595d..aefce1e8e7 100644 --- a/devtools/merge_boundary.py +++ b/devtools/merge_boundary.py @@ -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") @@ -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) @@ -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 @@ -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), + 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, @@ -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: diff --git a/devtools/merge_gate.py b/devtools/merge_gate.py index 1c4c7a64bf..198cf027b2 100644 --- a/devtools/merge_gate.py +++ b/devtools/merge_gate.py @@ -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") @@ -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() @@ -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), @@ -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: @@ -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: @@ -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"] + if receipt.get("pr_scope_attestation_digest") != expected_attestation_digest: + 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 diff --git a/devtools/pr_scope.py b/devtools/pr_scope.py index 11f3e86fd7..e1a5654a0a 100644 --- a/devtools/pr_scope.py +++ b/devtools/pr_scope.py @@ -1,10 +1,10 @@ """Structured PR scope carrier used by CI and the merge boundary. -The carrier is an embedded JSON comment, not a convention inferred from PR -prose. It binds the declared Bead scope to a PR head, a canonical digest of the -assigned Bead records, whole-Bead dispositions, and concrete evidence refs. -Bead acceptance text is deliberately opaque here: it is hashed as part of the -record snapshot, never interpreted by this module. +The embedded v2 JSON comment is stable intent, not a mutable commit receipt. +The merge boundary binds that intent to the current PR head and canonical Bead +snapshot in its exact-head attestation. Bead acceptance text remains opaque: +the checker consumes typed identifiers, dispositions, evidence refs, and graph +links without interpreting prose. """ from __future__ import annotations @@ -23,14 +23,19 @@ from typing import Any from urllib import error, parse, request -_CARRIER_PREFIX = "polylogue-pr-scope:v1" -_CARRIER_START = f"" -_VERSION = 1 +_V1 = 1 +_VERSION = 2 _BEADS_PATH = Path(".beads/issues.jsonl") _GITHUB_API_URL = "https://api.github.com" _REPOSITORY_PATTERN = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+") -_CARRIER_KEYS = frozenset({"version", "head_sha", "assigned_beads", "beads_digest", "dispositions", "scope_digest"}) +_GIT_OBJECT_PATTERN = re.compile(r"[0-9a-fA-F]{7,64}") +_V1_CARRIER_KEYS = frozenset({"version", "head_sha", "assigned_beads", "beads_digest", "dispositions", "scope_digest"}) +_V2_CARRIER_KEYS = frozenset( + {"version", "scope_kind", "assigned_beads", "mutated_beads", "dispositions", "scope_digest"} +) _DISPOSITION_KEYS = frozenset({"bead_id", "disposition", "evidence", "successors"}) _EVIDENCE_KEYS = frozenset({"kind", "ref"}) @@ -51,11 +56,17 @@ class EvidenceKind(StrEnum): TEST = "test" +class ScopeKind(StrEnum): + BEAD = "bead" + SELF_CONTAINED = "self_contained" + + _DISPOSITIONS = frozenset(item.value for item in ScopeDisposition) _RESIDUAL_DISPOSITIONS = frozenset( {ScopeDisposition.PARTIAL.value, ScopeDisposition.DEFERRED.value, ScopeDisposition.SUPERSEDED.value} ) _EVIDENCE_KINDS = frozenset(item.value for item in EvidenceKind) +_SCOPE_KINDS = frozenset(item.value for item in ScopeKind) _SUCCESSOR_LINK_TYPES = frozenset({"blocks", "discovered-from", "relates-to", "supersedes"}) @@ -66,6 +77,7 @@ class ScopeVerdict: scope_digest: str | None = None beads_digest: str | None = None assigned_beads: list[str] = field(default_factory=list) + mutated_beads: list[str] = field(default_factory=list) @dataclass(frozen=True, slots=True) @@ -91,15 +103,12 @@ def _digest(value: object) -> str: return hashlib.sha256(_canonical_json(value).encode()).hexdigest() -def load_bead_records(path: Path = _BEADS_PATH) -> dict[str, dict[str, Any]]: - """Load the repository's committed Bead records without invoking ``bd``. - - The carrier needs the exact Bead snapshot that CI checked out. Reading the - JSONL avoids mutating shared Dolt state from a worktree and keeps this - validation independent of Beads' CLI synchronization hooks. - """ +def _parse_bead_records(lines: object, *, line_label: str) -> dict[str, dict[str, Any]]: + """Parse issue records from a Beads JSONL snapshot.""" + if not isinstance(lines, list) or not all(isinstance(line, str) for line in lines): + raise ValueError("Bead JSONL snapshot must contain text lines") records: dict[str, dict[str, Any]] = {} - for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + for line_no, line in enumerate(lines, start=1): if not line.strip(): continue raw = json.loads(line) @@ -107,17 +116,29 @@ def load_bead_records(path: Path = _BEADS_PATH) -> dict[str, dict[str, Any]]: continue bead_id = raw["id"] if bead_id in records: - raise ValueError(f"duplicate Bead id {bead_id!r} on line {line_no}") + raise ValueError(f"duplicate Bead id {bead_id!r} on {line_label} {line_no}") records[bead_id] = raw return records -def canonical_beads_digest(records: dict[str, dict[str, Any]], bead_ids: list[str]) -> str: +def load_bead_records(path: Path = _BEADS_PATH) -> dict[str, dict[str, Any]]: + """Load the repository's committed Bead records without invoking ``bd``. + + The carrier needs the exact Bead snapshot that CI checked out. Reading the + JSONL avoids mutating shared Dolt state from a worktree and keeps this + validation independent of Beads' CLI synchronization hooks. + """ + return _parse_bead_records(path.read_text(encoding="utf-8").splitlines(), line_label="line") + + +def canonical_beads_digest( + records: dict[str, dict[str, Any]], bead_ids: list[str], *, carrier_version: int = _V1 +) -> str: """Digest whole canonical records for the declared IDs, sorted by Bead ID.""" missing = [bead_id for bead_id in bead_ids if bead_id not in records] if missing: raise ValueError(f"assigned Bead record(s) missing: {', '.join(missing)}") - return _digest({"version": _VERSION, "records": [records[bead_id] for bead_id in sorted(bead_ids)]}) + return _digest({"version": carrier_version, "records": [records[bead_id] for bead_id in sorted(bead_ids)]}) def _successor_is_linked(source_id: str, successor_id: str, records: dict[str, dict[str, Any]]) -> bool: @@ -143,12 +164,13 @@ def carrier_digest(carrier: dict[str, Any]) -> str: def extract_carrier(body: str) -> tuple[dict[str, Any] | None, list[str]]: - starts = [index for index in range(len(body)) if body.startswith(_CARRIER_START, index)] + starts = list(_CARRIER_START_PATTERN.finditer(body)) if not starts: return None, ["PR body is missing the structured pr-scope carrier"] if len(starts) != 1: return None, ["PR body contains more than one structured pr-scope carrier"] - start = starts[0] + len(_CARRIER_START) + marker = starts[0] + start = marker.end() end = body.find(_CARRIER_END, start) if end < 0: return None, ["structured pr-scope carrier is missing its closing comment"] @@ -159,6 +181,9 @@ def extract_carrier(body: str) -> tuple[dict[str, Any] | None, list[str]]: return None, [f"structured pr-scope carrier is not valid JSON: {exc.msg}"] if not isinstance(carrier, dict): return None, ["structured pr-scope carrier must be a JSON object"] + carrier_version = carrier.get("version") + if type(carrier_version) is not int or carrier_version != int(marker.group("version")): + return None, ["structured pr-scope carrier version does not match its comment marker"] return carrier, [] @@ -178,34 +203,218 @@ def _validate_keys( reasons.append(f"{label} is missing required field(s): {', '.join(missing)}") +def _bead_ids(value: object, *, label: str, allow_empty: bool, reasons: list[str]) -> list[str]: + if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): + reasons.append(f"{label} must be a list of Bead IDs") + return [] + if not value and not allow_empty: + reasons.append(f"{label} must be a non-empty list of Bead IDs") + if len(set(value)) != len(value): + reasons.append(f"{label} contains duplicate Bead IDs") + return value + + +def _bead_records_at(base_sha: str) -> dict[str, dict[str, Any]]: + if not _GIT_OBJECT_PATTERN.fullmatch(base_sha): + raise ValueError("base revision must be a hexadecimal Git object name") + result = subprocess.run( + ["git", "show", f"{base_sha}:.beads/issues.jsonl"], capture_output=True, text=True, check=False + ) + if result.returncode != 0: + raise ValueError(f"cannot read .beads/issues.jsonl at base revision {base_sha[:8]}") + return _parse_bead_records(result.stdout.splitlines(), line_label="base line") + + +def _ensure_local_commit(revision: str) -> None: + """Ensure a Git commit reported by GitHub exists in this checkout. + + Feature worktrees and shallow CI checkouts can have a current PR head while + lacking a target-branch tip that advanced after the checkout was created. + Mutation scope is defined from the merge base, so resolve that authority + explicitly instead of requiring an operator-side ``git fetch`` first. + """ + present = subprocess.run( + ["git", "cat-file", "-e", f"{revision}^{{commit}}"], + capture_output=True, + check=False, + ) + if present.returncode == 0: + return + fetched = subprocess.run( + ["git", "fetch", "--no-tags", "--quiet", "origin", revision], + capture_output=True, + text=True, + check=False, + timeout=120, + ) + if fetched.returncode != 0: + detail = fetched.stderr.strip() + suffix = f": {detail}" if detail else "" + raise ValueError(f"cannot fetch base revision {revision[:8]} from origin{suffix}") + present = subprocess.run( + ["git", "cat-file", "-e", f"{revision}^{{commit}}"], + capture_output=True, + check=False, + ) + if present.returncode != 0: + raise ValueError(f"fetched base revision {revision[:8]} is not a local commit") + + +def _merge_base(base_sha: str) -> str: + """Resolve the PR merge base, recovering complete history in shallow CI clones.""" + result = subprocess.run(["git", "merge-base", base_sha, "HEAD"], capture_output=True, text=True, check=False) + if result.returncode == 0 and _GIT_OBJECT_PATTERN.fullmatch(result.stdout.strip()): + return result.stdout.strip() + + shallow = subprocess.run( + ["git", "rev-parse", "--is-shallow-repository"], capture_output=True, text=True, check=False + ) + if shallow.returncode == 0 and shallow.stdout.strip() == "true": + fetched = subprocess.run( + ["git", "fetch", "--no-tags", "--quiet", "--unshallow", "origin"], + capture_output=True, + text=True, + check=False, + timeout=120, + ) + if fetched.returncode != 0: + detail = fetched.stderr.strip() + suffix = f": {detail}" if detail else "" + raise ValueError(f"cannot deepen shallow history from origin{suffix}") + result = subprocess.run(["git", "merge-base", base_sha, "HEAD"], capture_output=True, text=True, check=False) + if result.returncode == 0 and _GIT_OBJECT_PATTERN.fullmatch(result.stdout.strip()): + return result.stdout.strip() + + raise ValueError(f"cannot resolve PR merge base from base revision {base_sha[:8]}") + + +def changed_bead_ids(*, base_sha: str, beads_path: Path = _BEADS_PATH) -> list[str]: + """Return the complete Bead-record mutation set from the PR merge base.""" + if not _GIT_OBJECT_PATTERN.fullmatch(base_sha): + raise ValueError("base revision must be a hexadecimal Git object name") + _ensure_local_commit(base_sha) + before = _bead_records_at(_merge_base(base_sha)) + after = load_bead_records(beads_path) + return sorted(bead_id for bead_id in set(before) | set(after) if before.get(bead_id) != after.get(bead_id)) + + +def _validate_dispositions( + dispositions: object, + *, + assigned_ids: list[str], + records: dict[str, dict[str, Any]], + reasons: list[str], +) -> None: + by_bead: dict[str, dict[str, Any]] = {} + if not isinstance(dispositions, list): + reasons.append("dispositions must be a list with one entry per assigned Bead") + return + for entry in dispositions: + if not isinstance(entry, dict) or not isinstance(entry.get("bead_id"), str): + reasons.append("each disposition must be an object with a bead_id") + continue + _validate_keys( + entry, + label=f"disposition for {entry['bead_id']}", + allowed=_DISPOSITION_KEYS, + required=_DISPOSITION_KEYS, + reasons=reasons, + ) + bead_id = entry["bead_id"] + if bead_id in by_bead: + reasons.append(f"duplicate disposition for assigned Bead {bead_id}") + by_bead[bead_id] = entry + missing = sorted(set(assigned_ids) - set(by_bead)) + extra = sorted(set(by_bead) - set(assigned_ids)) + if missing: + reasons.append(f"missing whole-Bead disposition(s): {', '.join(missing)}") + if extra: + reasons.append(f"disposition(s) reference unassigned Bead(s): {', '.join(extra)}") + for bead_id, entry in by_bead.items(): + disposition = entry.get("disposition") + if disposition not in _DISPOSITIONS: + reasons.append(f"{bead_id}: unknown whole-Bead disposition {disposition!r}") + evidence = entry.get("evidence") + if not isinstance(evidence, list) or not evidence: + reasons.append(f"{bead_id}: disposition needs at least one typed evidence reference") + else: + for ref in evidence: + if isinstance(ref, dict): + _validate_keys( + ref, + label=f"evidence for {bead_id}", + allowed=_EVIDENCE_KEYS, + required=_EVIDENCE_KEYS, + reasons=reasons, + ) + if ( + not isinstance(ref, dict) + or ref.get("kind") not in _EVIDENCE_KINDS + or not isinstance(ref.get("ref"), str) + or not ref["ref"].strip() + ): + reasons.append(f"{bead_id}: evidence references need a known kind and non-empty ref") + break + successors = entry.get("successors", []) + if not isinstance(successors, list) or not all(isinstance(item, str) and item for item in successors): + reasons.append(f"{bead_id}: successors must be a list of Bead IDs") + continue + if len(set(successors)) != len(successors): + reasons.append(f"{bead_id}: successors contains duplicate Bead IDs") + if disposition in _RESIDUAL_DISPOSITIONS and not successors: + reasons.append(f"{bead_id}: {disposition} disposition requires a named successor Bead") + if disposition == "satisfied" and successors: + reasons.append(f"{bead_id}: satisfied disposition cannot carry residual successors") + for successor in successors: + record = records.get(successor) + if record is None: + reasons.append(f"{bead_id}: successor {successor} is unknown") + elif record.get("status") == "closed": + reasons.append(f"{bead_id}: successor {successor} is closed") + elif not _successor_is_linked(bead_id, successor, records): + reasons.append(f"{bead_id}: successor {successor} has no durable Beads relationship") + if successor == bead_id: + reasons.append(f"{bead_id}: cannot name itself as a successor") + + def validate_carrier( carrier: dict[str, Any], *, head_sha: str, is_draft: bool, beads_path: Path = _BEADS_PATH, + base_sha: str | None = None, ) -> ScopeVerdict: reasons: list[str] = [] + version = carrier.get("version") + if type(version) is not int or version not in {_V1, _VERSION}: + return ScopeVerdict(ok=False, reasons=[f"carrier version must be {_V1} or {_VERSION}"]) + is_v1 = version == _V1 _validate_keys( carrier, label="carrier", - allowed=_CARRIER_KEYS, - required=_CARRIER_KEYS, + allowed=_V1_CARRIER_KEYS if is_v1 else _V2_CARRIER_KEYS, + required=_V1_CARRIER_KEYS if is_v1 else _V2_CARRIER_KEYS, reasons=reasons, ) - assigned = carrier.get("assigned_beads") - if not isinstance(assigned, list) or not assigned or not all(isinstance(item, str) and item for item in assigned): - reasons.append("assigned_beads must be a non-empty list of Bead IDs") - assigned_ids: list[str] = [] + assigned_ids = _bead_ids( + carrier.get("assigned_beads"), label="assigned_beads", allow_empty=not is_v1, reasons=reasons + ) + mutated_ids: list[str] = [] + if is_v1: + if carrier.get("head_sha") != head_sha: + reasons.append("carrier head_sha does not match the PR head SHA") else: - assigned_ids = assigned - if len(set(assigned_ids)) != len(assigned_ids): - reasons.append("assigned_beads contains duplicate Bead IDs") - - if carrier.get("version") != _VERSION: - reasons.append(f"carrier version must be {_VERSION}") - if carrier.get("head_sha") != head_sha: - reasons.append("carrier head_sha does not match the PR head SHA") + scope_kind = carrier.get("scope_kind") + if scope_kind not in _SCOPE_KINDS: + reasons.append(f"scope_kind must be one of: {', '.join(sorted(_SCOPE_KINDS))}") + mutated_ids = _bead_ids(carrier.get("mutated_beads"), label="mutated_beads", allow_empty=True, reasons=reasons) + if scope_kind == ScopeKind.BEAD.value and not assigned_ids: + reasons.append("bead scope requires at least one assigned Bead") + if scope_kind == ScopeKind.SELF_CONTAINED.value and ( + assigned_ids or mutated_ids or carrier.get("dispositions") != [] + ): + reasons.append("self_contained scope cannot declare or mutate Beads") if is_draft: reasons.append("PR is draft; publish a non-draft PR before validation") @@ -215,97 +424,71 @@ def validate_carrier( reasons.append("carrier scope_digest does not match its canonical content") records: dict[str, dict[str, Any]] = {} - if assigned_ids: - try: - records = load_bead_records(beads_path) - expected_beads_digest = canonical_beads_digest(records, assigned_ids) - except (OSError, ValueError, json.JSONDecodeError) as exc: - expected_beads_digest = None - reasons.append(f"cannot resolve assigned Bead records: {exc}") - if expected_beads_digest is not None and carrier.get("beads_digest") != expected_beads_digest: - reasons.append("carrier beads_digest is stale for the canonical assigned Bead records") - else: + try: + records = load_bead_records(beads_path) + missing_assigned = [bead_id for bead_id in assigned_ids if bead_id not in records] + if missing_assigned: + reasons.append(f"assigned Bead record(s) missing: {', '.join(missing_assigned)}") + bound_ids = list(assigned_ids) + if not is_v1: + bound_ids.extend(mutated_ids) + dispositions = carrier.get("dispositions") + for entry in dispositions if isinstance(dispositions, list) else []: + if isinstance(entry, dict) and isinstance(entry.get("successors"), list): + bound_ids.extend(item for item in entry["successors"] if isinstance(item, str)) + bound_ids = sorted({bead_id for bead_id in bound_ids if bead_id in records}) + expected_beads_digest = canonical_beads_digest(records, bound_ids, carrier_version=int(version)) + except (OSError, ValueError, json.JSONDecodeError) as exc: expected_beads_digest = None + reasons.append(f"cannot resolve declared Bead records: {exc}") + if is_v1 and expected_beads_digest is not None and carrier.get("beads_digest") != expected_beads_digest: + reasons.append("carrier beads_digest is stale for the canonical assigned Bead records") + actual_mutations: list[str] = [] + if base_sha is not None: + try: + actual_mutations = changed_bead_ids(base_sha=base_sha, beads_path=beads_path) + except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as exc: + reasons.append(f"cannot resolve Bead mutation scope: {exc}") + else: + if is_v1 and actual_mutations: + reasons.append("legacy v1 carrier cannot omit Bead mutations; render a v2 carrier") + elif not is_v1 and actual_mutations != sorted(mutated_ids): + reasons.append("mutated_beads does not match the complete Bead mutation set") + disposition_records = records dispositions = carrier.get("dispositions") - by_bead: dict[str, dict[str, Any]] = {} - if not isinstance(dispositions, list): - reasons.append("dispositions must be a list with one entry per assigned Bead") - else: - for entry in dispositions: - if not isinstance(entry, dict) or not isinstance(entry.get("bead_id"), str): - reasons.append("each disposition must be an object with a bead_id") - continue - _validate_keys( - entry, - label=f"disposition for {entry['bead_id']}", - allowed=_DISPOSITION_KEYS, - required=_DISPOSITION_KEYS, - reasons=reasons, - ) - bead_id = entry["bead_id"] - if bead_id in by_bead: - reasons.append(f"duplicate disposition for assigned Bead {bead_id}") - by_bead[bead_id] = entry - missing = sorted(set(assigned_ids) - set(by_bead)) - extra = sorted(set(by_bead) - set(assigned_ids)) - if missing: - reasons.append(f"missing whole-Bead disposition(s): {', '.join(missing)}") - if extra: - reasons.append(f"disposition(s) reference unassigned Bead(s): {', '.join(extra)}") - - for bead_id, entry in by_bead.items(): - disposition = entry.get("disposition") - if disposition not in _DISPOSITIONS: - reasons.append(f"{bead_id}: unknown whole-Bead disposition {disposition!r}") - evidence = entry.get("evidence") - if not isinstance(evidence, list) or not evidence: - reasons.append(f"{bead_id}: disposition needs at least one typed evidence reference") - else: - for ref in evidence: - if isinstance(ref, dict): - _validate_keys( - ref, - label=f"evidence for {bead_id}", - allowed=_EVIDENCE_KEYS, - required=_EVIDENCE_KEYS, - reasons=reasons, - ) - if ( - not isinstance(ref, dict) - or ref.get("kind") not in _EVIDENCE_KINDS - or not isinstance(ref.get("ref"), str) - or not ref["ref"].strip() - ): - reasons.append(f"{bead_id}: evidence references need a known kind and non-empty ref") - break - successors = entry.get("successors", []) - if not isinstance(successors, list) or not all(isinstance(item, str) and item for item in successors): - reasons.append(f"{bead_id}: successors must be a list of Bead IDs") - continue - if len(set(successors)) != len(successors): - reasons.append(f"{bead_id}: successors contains duplicate Bead IDs") - if disposition in _RESIDUAL_DISPOSITIONS and not successors: - reasons.append(f"{bead_id}: {disposition} disposition requires a named successor Bead") - if disposition == "satisfied" and successors: - reasons.append(f"{bead_id}: satisfied disposition cannot carry residual successors") - for successor in successors: - record = records.get(successor) - if record is None: - reasons.append(f"{bead_id}: successor {successor} is unknown") - elif record.get("status") == "closed": - reasons.append(f"{bead_id}: successor {successor} is closed") - elif not _successor_is_linked(bead_id, successor, records): - reasons.append(f"{bead_id}: successor {successor} has no durable Beads relationship") - if successor == bead_id: - reasons.append(f"{bead_id}: cannot name itself as a successor") + disposition_entries = dispositions if isinstance(dispositions, list) else [] + successor_ids = { + successor + for entry in disposition_entries + if isinstance(entry, dict) + for successor in entry.get("successors", []) + if isinstance(entry.get("successors"), list) and isinstance(successor, str) + } + if base_sha is not None and successor_ids: + try: + disposition_records = _bead_records_at(base_sha) + for bead_id in actual_mutations: + if bead_id in records: + disposition_records[bead_id] = records[bead_id] + else: + disposition_records.pop(bead_id, None) + except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as exc: + reasons.append(f"cannot resolve prospective Bead state: {exc}") + _validate_dispositions( + dispositions, + assigned_ids=assigned_ids, + records=disposition_records, + reasons=reasons, + ) return ScopeVerdict( ok=not reasons, reasons=reasons, scope_digest=scope_digest if isinstance(scope_digest, str) else None, - beads_digest=carrier.get("beads_digest") if isinstance(carrier.get("beads_digest"), str) else None, + beads_digest=expected_beads_digest, assigned_beads=assigned_ids, + mutated_beads=mutated_ids, ) @@ -315,27 +498,36 @@ def validate_pr_body( head_sha: str, is_draft: bool, beads_path: Path = _BEADS_PATH, + base_sha: str | None = None, ) -> ScopeVerdict: carrier, reasons = extract_carrier(body) if carrier is None: return ScopeVerdict(ok=False, reasons=reasons) - return validate_carrier(carrier, head_sha=head_sha, is_draft=is_draft, beads_path=beads_path) + return validate_carrier(carrier, head_sha=head_sha, is_draft=is_draft, beads_path=beads_path, base_sha=base_sha) def render_carrier(carrier: dict[str, Any]) -> str: - return f"{_CARRIER_START}\n{json.dumps(carrier, indent=2, ensure_ascii=False, sort_keys=True)}\n{_CARRIER_END}" + version = carrier.get("version") + return f"