From fb95992bf2f0dbcddef4aa5fdcca8a14fe8acac7 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 6 Aug 2026 08:05:55 +0200 Subject: [PATCH 01/10] feat(devtools): enforce structured PR scope carrier Problem: PR scope and residual Bead work were inferred from prose and did not reach CI or the merge boundary. What changed: add a versioned embedded JSON carrier, command-surface render/check commands, CircleCI enforcement, merge-gate receipt binding, and lane/reviewer instructions. Replace the prose reconciliation probe with typed whole-Bead dispositions and successor validation. Verification: devtools test tests/unit/devtools/test_pr_scope.py tests/unit/devtools/test_merge_gate.py tests/unit/devtools/test_merge_boundary.py (49 passed); devtools verify --quick (success). --- .circleci/config.yml | 11 + .codex/agents/narrow-worker.toml | 7 + .codex/agents/reviewer.toml | 6 + .codex/agents/worker.toml | 9 + .github/pull_request_template.md | 16 ++ CLAUDE.md | 14 +- devtools/command_catalog.py | 43 ++- devtools/lane_brief.py | 12 + devtools/merge_boundary.py | 12 +- devtools/merge_gate.py | 36 ++- devtools/pr_scope.py | 314 +++++++++++++++++++++ devtools/verify_bead_pr_reconciliation.py | 213 -------------- docs/devtools.md | 3 +- docs/tracker-authority.md | 1 + tests/unit/devtools/test_merge_boundary.py | 60 +++- tests/unit/devtools/test_merge_gate.py | 40 ++- tests/unit/devtools/test_pr_scope.py | 145 ++++++++++ 17 files changed, 692 insertions(+), 250 deletions(-) create mode 100644 devtools/pr_scope.py delete mode 100644 devtools/verify_bead_pr_reconciliation.py create mode 100644 tests/unit/devtools/test_pr_scope.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 1f761c4b90..d7b9a9a5bf 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -71,6 +71,17 @@ jobs: - run: name: Public claims gate command: ~/.local/bin/uv run devtools verify public-claims --json + - run: + name: Structured PR scope carrier + command: | + if [ -z "${CIRCLE_PULL_REQUEST:-}" ]; then + if [ "${CIRCLE_BRANCH:-}" = "master" ]; then + exit 0 + fi + echo "quick-gate requires CIRCLE_PULL_REQUEST for every non-master build" >&2 + exit 1 + fi + ~/.local/bin/uv run devtools workspace pr-scope check --pr "${CIRCLE_PULL_REQUEST##*/}" - run: name: devtools verify --quick command: ~/.local/bin/uv run devtools verify --quick diff --git a/.codex/agents/narrow-worker.toml b/.codex/agents/narrow-worker.toml index 90dc069931..da1bda7b90 100644 --- a/.codex/agents/narrow-worker.toml +++ b/.codex/agents/narrow-worker.toml @@ -26,4 +26,11 @@ write to Beads, do not merge or push, do not touch /realm/db/polylogue. Commit coherent checkpoints. Return exact changed files, commands run and their output, an acceptance-criteria match table, and residual uncertainty. + +For a lane that publishes a PR, emit the versioned PR-scope carrier before the +non-draft PR is opened: render it from assigned Bead IDs, typed whole-Bead +dispositions, evidence refs, and open successors for residual scope using +`devtools workspace pr-scope render`. Embed the result in the PR body and run +`devtools workspace pr-scope check --pr `. Never derive a disposition by +parsing acceptance prose or fabricate a successor Bead ID. """ diff --git a/.codex/agents/reviewer.toml b/.codex/agents/reviewer.toml index 87a540a376..9b16e67e95 100644 --- a/.codex/agents/reviewer.toml +++ b/.codex/agents/reviewer.toml @@ -26,4 +26,10 @@ site). 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. """ diff --git a/.codex/agents/worker.toml b/.codex/agents/worker.toml index 590fdfa5e2..1414d3bec7 100644 --- a/.codex/agents/worker.toml +++ b/.codex/agents/worker.toml @@ -34,4 +34,13 @@ turns; uncommitted work is lost work). When done, return: exact root cause; changed files; the exact commands you ran and their output; an acceptance-criteria match table against the bead(s) you were assigned; residual uncertainty; and the commit hash(es). + +Before publishing a non-draft PR, create a JSON scope input that names every +assigned Bead, one whole-Bead disposition per ID, typed evidence refs, and an +existing open successor for every partial/deferred/superseded outcome. Render +the embedded carrier with `devtools workspace pr-scope render`, put that exact +comment in the PR body beside the human disposition matrix, and validate the +published PR with `devtools workspace pr-scope check --pr `. Do not ask a +machine to infer Bead acceptance from prose and do not invent missing Bead IDs; +report missing IDs to the coordinator. """ diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c5c8b53c08..263e6ae83a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,6 +18,22 @@ _What was done. Key modules, contracts, and boundaries touched._ _Exact commands run and any manual validation performed._ +## Bead disposition matrix + +| Assigned Bead | Whole-Bead disposition | Evidence refs | Named successor for residual work | +| --- | --- | --- | --- | +| `polylogue-...` | satisfied / partial / deferred / superseded | `test:...`, `command:...` | `polylogue-...` or n/a | + + + ## Changelog _If user-visible (new flags, renamed/removed commands, output changes, breaking migrations, security fixes), add a one-line entry to the `Unreleased` section of `CHANGELOG.md`. Skip for refactors, internal renames, and test-only PRs._ diff --git a/CLAUDE.md b/CLAUDE.md index dfc35561a0..80e1f5affc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -378,6 +378,12 @@ workflow, not optional conveniences — use them at the point named, every time: one branch instead of colliding across parallel lanes. - **When dispatching a worktree-isolated lane**: `devtools workspace lane-brief --out ` for its dispatch prompt (footprint, prior art, hazards). +- **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. - **Immediately after spawning a worktree-isolated lane, not after it reports back**: `devtools workspace verify-worktree --expect-branch ` — confirms the worktree is real and isolated before the lane has @@ -388,10 +394,11 @@ workflow, not optional conveniences — use them at the point named, every time: - **To squash-merge any PR**: use `devtools workspace merge ` instead of a bare `gh pr merge --squash` — it wraps `merge-gate record`/`check` at the actual merge boundary instead of leaving them a step a coordinator must - remember. It auto-records a receipt if none is fresh for the current head + remember. It validates the current non-draft PR's structured scope carrier, + 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, or an unacked review comment newer than the head commit), strips a + exit, a changed carrier digest, 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 @@ -457,7 +464,8 @@ All product code lands via **feature branches + squash-merged PRs** to `master` ≤72 chars, imperative, describes what changed. Ends up as permanent history. - PR body sections (all required): **Summary**, **Problem** (evidence, not "user asked"), **Solution** (modules touched, non-obvious decisions), **Verification** - (exact commands + the output line that matters, not "tests pass"). + (exact commands + the output line that matters, not "tests pass"), and a + human whole-Bead disposition matrix plus the rendered `pr-scope` carrier. - Routine PRs do **not** edit `pyproject.toml` `version` or `CHANGELOG.md` — release-please owns those from conventional subjects on `master`. - **Claim verification:** before writing that something is "unified"/"aligned"/ diff --git a/devtools/command_catalog.py b/devtools/command_catalog.py index 42b5e7e3da..f1990df59e 100644 --- a/devtools/command_catalog.py +++ b/devtools/command_catalog.py @@ -22,7 +22,6 @@ "lab policy schema-versioning", "lab policy timestamp-doctrine", "lab provider completeness", - "lab probe bead-pr-reconciliation", "lab probe capture-regression", "lab probe cost-reconciliation", "lab probe pipeline", @@ -769,6 +768,23 @@ class CatalogBypassSite: "devtools workspace verify-worktree /realm/worktrees/lane-x --json --strict", ), ), + CommandSpec( + "workspace pr-scope", + "workspace", + "Render and validate the versioned PR Bead-scope carrier.", + "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." + ), + 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 check --body-file pr-body.md --head-sha $(git rev-parse HEAD)", + ), + ), CommandSpec( "workspace merge-gate", "workspace", @@ -779,13 +795,13 @@ class CatalogBypassSite: "(grace-period comment polling, remembering to run the broader local test suite CI skips " 'per-PR) with a check that fails closed. `record --command "..."` requires the current ' "checkout to already be the PR's exact head commit with a clean tree (it refuses otherwise), " - "runs a local verification " + "first validates the same versioned PR-scope carrier CircleCI checks, then runs a local verification " "command, and persists a receipt flagging commands that look like they skip tests (e.g. " "`verify --quick`). `check ` polls review comments across a real grace window (default " "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. Motivated by two 2026-08-01 incidents: PR #3502 merged before CodeRabbit's findings " + "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 " "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 " @@ -814,7 +830,7 @@ class CatalogBypassSite: "(polylogue-ct3r2 / polylogue-t6iga: duplicate filings of the same finding -- " "`merge-gate record/check` and the one-full-verify-per-train rule both existed but " "fired only if a coordinator remembered to invoke them). `merge ` auto-records a " - "merge-gate receipt if none is fresh for the current head sha (running `--command`, " + "validates the non-draft PR's structured scope carrier and auto-records a merge-gate receipt if none is fresh for the current head sha (running `--command`, " 'default "devtools verify"), runs `merge-gate check` and refuses to merge on any ' "BLOCK, strips a doubled `(#N) (#N)` squash-subject suffix (the 2026-07-12/13 " "incident), then runs the actual `gh pr merge --squash`. `--dry-run` runs every check " @@ -2012,25 +2028,6 @@ class CatalogBypassSite: ), examples=("devtools release verify-distribution",), ), - CommandSpec( - "lab probe bead-pr-reconciliation", - "verification lab", - "Surface beads whose referenced PR merged but the bead is still open.", - "devtools.verify_bead_pr_reconciliation", - use_when=( - "After a merge-heavy stretch (a Workflow campaign, a merge train, or just several PRs " - "landed close together), check for beads left open by a PR that referenced them -- catches " - "the reconciliation gap where workers/agents are barred from closing beads themselves and no " - "follow-up pass ever ran (2026-07-14: a 55-bead campaign left every bead open despite ~20 " - "PRs merging clean). Advisory only -- reports candidates for a human/agent AC check, never " - "auto-closes and never fails a gate." - ), - examples=( - "devtools lab probe bead-pr-reconciliation", - "devtools lab probe bead-pr-reconciliation --since 2026-07-01 --json", - "devtools lab probe bead-pr-reconciliation --limit 50", - ), - ), CommandSpec( "lab probe cost-reconciliation", "verification lab", diff --git a/devtools/lane_brief.py b/devtools/lane_brief.py index c631c8fa7e..3c7bfa4011 100644 --- a/devtools/lane_brief.py +++ b/devtools/lane_brief.py @@ -89,6 +89,14 @@ "verify locally." ) +_PR_SCOPE_CARRIER = ( + "Before opening a non-draft PR, render the structured carrier from assigned 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 `. " + "Do not infer acceptance from Bead prose or invent missing Bead IDs." +) + @dataclass class BeadRecord: @@ -422,6 +430,10 @@ def _render_markdown( lines.append("") lines.append(_VERIFICATION_TIER) lines.append("") + lines.append("## PR scope carrier") + lines.append("") + lines.append(_PR_SCOPE_CARRIER) + lines.append("") return "\n".join(lines) diff --git a/devtools/merge_boundary.py b/devtools/merge_boundary.py index 9dbc51b9c6..ab3479e514 100644 --- a/devtools/merge_boundary.py +++ b/devtools/merge_boundary.py @@ -22,18 +22,20 @@ which: 1. Refuses unless the PR is OPEN. - 2. If no fresh ``merge-gate`` receipt exists for the PR's *current* head + 2. Requires the versioned ``pr-scope`` carrier on the current non-draft PR, + including its current Bead-record digest and whole-Bead dispositions. + 3. If no fresh ``merge-gate`` receipt exists for the PR's *current* head sha, records one automatically (running ``--command``, default ``devtools verify``) instead of just failing and telling the caller to go run a separate command first. - 3. Runs ``merge-gate check`` (late-review-comment grace-window poll + + 4. Runs ``merge-gate check`` (late-review-comment grace-window poll + receipt freshness/exit-code checks). Refuses to merge on any BLOCK. - 4. Applies title hygiene: strips a doubled ``(#N) (#N)`` suffix (the + 5. Applies title hygiene: strips a doubled ``(#N) (#N)`` suffix (the 2026-07-12/13 incident where the squash-merge subject carried the PR number twice because a manual ``gh pr edit --title`` step was skipped) and ensures exactly one trailing ``(#N)``. - 5. Runs the actual ``gh pr merge --squash``. - 6. Appends a merge-train ledger entry (``.cache/verify/merge-gate/merge-train-ledger.json``) + 6. Runs the actual ``gh pr merge --squash``. + 7. Appends a merge-train ledger entry (``.cache/verify/merge-gate/merge-train-ledger.json``) and, unless ``--with-verify`` was given, prints a reminder that the ledger's terminal step -- one full-suite ``devtools verify --all`` (or narrower agreed selection) since the last one -- has not yet been diff --git a/devtools/merge_gate.py b/devtools/merge_gate.py index dfdb9924f7..b55ce69fb3 100644 --- a/devtools/merge_gate.py +++ b/devtools/merge_gate.py @@ -78,6 +78,8 @@ from pathlib import Path from typing import Any +from devtools import pr_scope + _RECEIPT_DIR = Path(".cache/verify/merge-gate") _DEFAULT_MAX_AGE_S = 3600 _DEFAULT_POLL_ROUNDS = 3 @@ -198,6 +200,7 @@ class GateVerdict: receipt: dict[str, Any] | None = None late_comments: list[dict[str, Any]] = field(default_factory=list) status_post: dict[str, Any] | None = None + pr_scope: dict[str, Any] | None = None def _receipt_path(pr: int) -> Path: @@ -216,9 +219,20 @@ def _command_skips_tests(command: str) -> bool: def cmd_record(pr: int, command: str) -> int: - info = _gh_json(["pr", "view", str(pr), "--json", "headRefOid,headRefName"]) + info = _gh_json(["pr", "view", str(pr), "--json", "headRefOid,headRefName,body,isDraft"]) head_sha = info["headRefOid"] + scope = pr_scope.validate_pr_body( + info.get("body") or "", + head_sha=head_sha, + is_draft=bool(info.get("isDraft")), + ) + if not scope.ok: + print(f"REFUSING to record: PR #{pr} has an invalid structured pr-scope carrier:", file=sys.stderr) + for reason in scope.reasons: + print(f" - {reason}", file=sys.stderr) + return 2 + local_head = _git_head_sha() if local_head != head_sha: print( @@ -252,6 +266,9 @@ def cmd_record(pr: int, command: str) -> int: receipt = { "pr": pr, "head_sha": head_sha, + "pr_scope_digest": scope.scope_digest, + "pr_scope_beads_digest": scope.beads_digest, + "pr_scope_assigned_beads": scope.assigned_beads, "branch": info["headRefName"], "command": command, "skips_tests": _command_skips_tests(command), @@ -380,7 +397,7 @@ def cmd_check( "view", str(pr), "--json", - "headRefOid,mergeStateStatus,state,commits", + "headRefOid,mergeStateStatus,state,commits,body,isDraft", ] ) except (RuntimeError, json.JSONDecodeError, OSError, subprocess.SubprocessError) as exc: @@ -393,6 +410,16 @@ def cmd_check( head_sha = info["headRefOid"] verdict.head_sha = head_sha + scope = pr_scope.validate_pr_body( + info.get("body") or "", + head_sha=head_sha, + is_draft=bool(info.get("isDraft")), + ) + verdict.pr_scope = asdict(scope) + if not scope.ok: + verdict.ok = False + verdict.reasons.extend(f"pr-scope: {reason}" for reason in scope.reasons) + if info.get("state") != "OPEN": verdict.ok = False verdict.reasons.append(f"PR state is {info.get('state')!r}, not OPEN") @@ -429,6 +456,11 @@ def cmd_check( "-- a new commit landed since the receipt was recorded; re-record before merging" ) else: + if receipt.get("pr_scope_digest") != scope.scope_digest: + verdict.ok = False + verdict.reasons.append( + "receipt pr_scope_digest does not match the current carrier -- re-record after scope changes" + ) 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 new file mode 100644 index 0000000000..4b20a983fc --- /dev/null +++ b/devtools/pr_scope.py @@ -0,0 +1,314 @@ +"""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. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +import sys +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +_CARRIER_PREFIX = "polylogue-pr-scope:v1" +_CARRIER_START = f"" +_VERSION = 1 +_BEADS_PATH = Path(".beads/issues.jsonl") +_DISPOSITIONS = frozenset({"satisfied", "partial", "deferred", "superseded"}) +_RESIDUAL_DISPOSITIONS = frozenset({"partial", "deferred", "superseded"}) +_EVIDENCE_KINDS = frozenset({"command", "commit", "diff", "receipt", "review", "test"}) + + +@dataclass(frozen=True, slots=True) +class ScopeVerdict: + ok: bool + reasons: list[str] = field(default_factory=list) + scope_digest: str | None = None + beads_digest: str | None = None + assigned_beads: list[str] = field(default_factory=list) + + +def _canonical_json(value: object) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +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. + """ + records: dict[str, dict[str, Any]] = {} + for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if not line.strip(): + continue + raw = json.loads(line) + if not isinstance(raw, dict) or raw.get("_type") != "issue" or not isinstance(raw.get("id"), str): + continue + bead_id = raw["id"] + if bead_id in records: + raise ValueError(f"duplicate Bead id {bead_id!r} on line {line_no}") + records[bead_id] = raw + return records + + +def canonical_beads_digest(records: dict[str, dict[str, Any]], bead_ids: list[str]) -> 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)]}) + + +def carrier_digest(carrier: dict[str, Any]) -> str: + """Digest a carrier excluding its self-referential digest field.""" + payload = dict(carrier) + payload.pop("scope_digest", None) + return _digest(payload) + + +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)] + 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) + end = body.find(_CARRIER_END, start) + if end < 0: + return None, ["structured pr-scope carrier is missing its closing comment"] + payload = body[start:end].strip() + try: + carrier = json.loads(payload) + except json.JSONDecodeError as exc: + 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"] + return carrier, [] + + +def validate_carrier( + carrier: dict[str, Any], + *, + head_sha: str, + is_draft: bool, + beads_path: Path = _BEADS_PATH, +) -> ScopeVerdict: + reasons: list[str] = [] + 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] = [] + 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") + if is_draft: + reasons.append("PR is draft; publish a non-draft PR before validation") + + expected_scope_digest = carrier_digest(carrier) + scope_digest = carrier.get("scope_digest") + if not isinstance(scope_digest, str) or scope_digest != expected_scope_digest: + 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: + expected_beads_digest = None + + 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 + 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 ( + 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") + if successor == bead_id: + reasons.append(f"{bead_id}: cannot name itself as a successor") + + 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, + assigned_beads=assigned_ids, + ) + + +def validate_pr_body( + body: str, + *, + head_sha: str, + is_draft: bool, + beads_path: Path = _BEADS_PATH, +) -> 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) + + +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}" + + +def build_carrier(input_payload: dict[str, Any], *, head_sha: str, beads_path: Path = _BEADS_PATH) -> dict[str, Any]: + carrier = dict(input_payload) + carrier["version"] = _VERSION + carrier["head_sha"] = head_sha + carrier.pop("beads_digest", None) + carrier.pop("scope_digest", None) + assigned = carrier.get("assigned_beads") + if not isinstance(assigned, list) or not all(isinstance(item, str) for item in assigned): + raise ValueError("input assigned_beads must be a list of Bead IDs") + carrier["beads_digest"] = canonical_beads_digest(load_bead_records(beads_path), assigned) + carrier["scope_digest"] = carrier_digest(carrier) + return carrier + + +def _git_head_sha() -> str: + result = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, check=True) + return result.stdout.strip() + + +def _pr_body(pr: int) -> tuple[str, str, bool]: + result = subprocess.run( + ["gh", "pr", "view", str(pr), "--json", "body,headRefOid,isDraft"], + capture_output=True, + text=True, + check=True, + ) + payload = json.loads(result.stdout) + return payload.get("body") or "", payload["headRefOid"], bool(payload.get("isDraft")) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="action", required=True) + + render = sub.add_parser("render", help="render a carrier from a JSON scope input") + render.add_argument("--input", required=True, type=Path, help="JSON with assigned_beads and dispositions") + render.add_argument("--head-sha", default=None, help="PR head SHA (default: current git HEAD)") + render.add_argument("--beads-path", type=Path, default=_BEADS_PATH) + + check = sub.add_parser("check", help="validate a PR's embedded carrier") + check_source = check.add_mutually_exclusive_group(required=True) + check_source.add_argument("--pr", type=int, help="GitHub PR number to inspect") + check_source.add_argument("--body-file", type=Path, help="PR body file for local validation") + check.add_argument("--head-sha", help="required with --body-file") + check.add_argument("--beads-path", type=Path, default=_BEADS_PATH) + check.add_argument("--json", action="store_true", dest="as_json") + + args = parser.parse_args(argv) + if args.action == "render": + try: + payload = json.loads(args.input.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("input must be a JSON object") + carrier = build_carrier(payload, head_sha=args.head_sha or _git_head_sha(), beads_path=args.beads_path) + except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as exc: + print(f"REFUSING to render pr-scope carrier: {exc}", file=sys.stderr) + return 2 + print(render_carrier(carrier)) + return 0 + + try: + if args.pr is not None: + body, head_sha, is_draft = _pr_body(args.pr) + else: + if not args.head_sha: + raise ValueError("--head-sha is required with --body-file") + body = args.body_file.read_text(encoding="utf-8") + head_sha = args.head_sha + is_draft = False + verdict = validate_pr_body(body, head_sha=head_sha, is_draft=is_draft, beads_path=args.beads_path) + except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as exc: + print(f"REFUSING to check pr-scope carrier: {exc}", file=sys.stderr) + return 2 + + if args.as_json: + print(json.dumps(asdict(verdict), indent=2)) + elif verdict.ok: + print(f"pr-scope OK @ {head_sha[:8]}: {', '.join(verdict.assigned_beads)}") + else: + print(f"pr-scope BLOCK @ {head_sha[:8]}:") + for reason in verdict.reasons: + print(f" - {reason}") + return 0 if verdict.ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/devtools/verify_bead_pr_reconciliation.py b/devtools/verify_bead_pr_reconciliation.py deleted file mode 100644 index 3ab5dbd9a1..0000000000 --- a/devtools/verify_bead_pr_reconciliation.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Surface beads whose referenced PR merged but the bead is still open. - -Background ----------- - -2026-07-14: a Workflow-driven fanout campaign (`.agent/scratch/wave2-campaign.js`, -55 beads across 7 clusters) explicitly barred worker agents from closing beads -("Do NOT close beads yourself... orchestrator runs merge-train after review"), -and the promised merge-train/reconciliation phase never ran. Every PR the -campaign produced was independently reviewed and merged -- the review loop -itself worked -- but zero beads were closed afterward. Recovering an accurate -picture took a multi-hour manual archaeology pass: reading every merged PR -body, cross-referencing bead IDs, checking each bead's own acceptance -criteria against what the diff actually did, then closing what was genuinely -satisfied (`bd close`) and leaving honest notes on what wasn't. - -This check turns the *detection* half of that archaeology into a fast, -repeatable command. It does not auto-close anything -- deciding whether a -merged PR actually satisfies a bead's AC needs judgment (this session found -real cases of a PR claiming a sweep was complete when it wasn't; see -polylogue-a7xr.9's notes). It only surfaces candidates: a still-open bead -mentioned in a merged PR's body is drift worth a human or agent look, not -proof of anything. - -Heuristic: scan `git log ..HEAD --grep='(#N)'` (squash-merge commits) -since `--since`, extract every `polylogue-` token from each commit's PR -body (via `gh pr view`), and report the ones whose bead is currently open. -Over-reports by design (a PR's own "deferred" section also mentions bead -IDs) -- the point is recall, not precision; a false positive costs one `bd -show` to dismiss, a false negative costs the multi-hour archaeology this -check exists to avoid repeating. -""" - -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -from dataclasses import dataclass, field - -from devtools import repo_root as _get_root - -_BEAD_ID_RE = re.compile(r"\bpolylogue-[a-z0-9]+(?:\.[0-9]+)*\b") -_PR_SUBJECT_RE = re.compile(r"\(#(\d+)\)\s*$") - - -@dataclass(frozen=True, slots=True) -class DriftCandidate: - bead_id: str - pr_number: int - pr_title: str - merged_at: str - bead_status: str - - -@dataclass(frozen=True, slots=True) -class _MergedPr: - number: int - title: str - merged_at: str - body: str = field(repr=False) - - -def _merged_prs_since(*, since: str, limit: int) -> list[_MergedPr]: - raw = subprocess.run( - [ - "gh", - "pr", - "list", - "--state", - "merged", - "--search", - f"merged:>={since}", - "--limit", - str(limit), - "--json", - "number,title,mergedAt,body", - ], - check=True, - capture_output=True, - text=True, - ).stdout - payload = json.loads(raw) - return [ - _MergedPr(number=item["number"], title=item["title"], merged_at=item["mergedAt"], body=item.get("body") or "") - for item in payload - ] - - -def _bead_statuses(bead_ids: set[str]) -> dict[str, str]: - if not bead_ids: - return {} - raw = subprocess.run( - ["bd", "list", "--limit", "0", "--json"], - check=True, - capture_output=True, - text=True, - ).stdout - decoder = json.JSONDecoder() - issues, _ = decoder.raw_decode(raw.lstrip()) - statuses = {item["id"]: item.get("status", "unknown") for item in issues} - raw_closed = subprocess.run( - ["bd", "list", "--status", "closed", "--limit", "0", "--json"], - check=True, - capture_output=True, - text=True, - ).stdout - closed_issues, _ = decoder.raw_decode(raw_closed.lstrip()) - statuses.update({item["id"]: item.get("status", "closed") for item in closed_issues}) - return statuses - - -def collect_findings(prs: list[_MergedPr]) -> list[DriftCandidate]: - per_pr_beads: dict[int, set[str]] = {} - all_ids: set[str] = set() - for pr in prs: - ids = {m.group(0) for m in _BEAD_ID_RE.finditer(pr.body)} - if ids: - per_pr_beads[pr.number] = ids - all_ids.update(ids) - - statuses = _bead_statuses(all_ids) - by_number = {pr.number: pr for pr in prs} - - findings: list[DriftCandidate] = [] - for number, ids in per_pr_beads.items(): - pr = by_number[number] - for bead_id in sorted(ids): - status = statuses.get(bead_id, "unknown") - if status not in ("closed",): - findings.append( - DriftCandidate( - bead_id=bead_id, - pr_number=number, - pr_title=pr.title, - merged_at=pr.merged_at, - bead_status=status, - ) - ) - findings.sort(key=lambda f: (f.bead_id, f.pr_number)) - return findings - - -def _format_report(findings: list[DriftCandidate], *, prs_scanned: int) -> str: - if not findings: - return f"bead/PR reconciliation: no drift candidates across {prs_scanned} merged PR(s) scanned." - lines = [f"{len(findings)} drift candidate(s) across {prs_scanned} merged PR(s) scanned:"] - for f in findings: - lines.append(f' {f.bead_id} ({f.bead_status}) <- PR #{f.pr_number} "{f.pr_title}" (merged {f.merged_at})') - lines.append( - "\nEach is a candidate, not a verdict -- check the bead's AC against what the PR actually did " - "(bd show --json; gh pr view --json body) before closing." - ) - return "\n".join(lines) - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser( - description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") - parser.add_argument( - "--since", - default=None, - help="only scan PRs merged on/after this date (YYYY-MM-DD); default: 14 days ago", - ) - parser.add_argument( - "--limit", - type=int, - default=200, - help="max merged PRs to scan (default: 200)", - ) - args = parser.parse_args(argv) - - _get_root() # validates we're inside the repo; gh/bd resolve the workspace themselves - - since = args.since - if since is None: - import datetime - - since = (datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=14)).strftime("%Y-%m-%d") - - prs = _merged_prs_since(since=since, limit=args.limit) - findings = collect_findings(prs) - - if args.json: - payload = { - "ok": not findings, - "prs_scanned": len(prs), - "since": since, - "findings": [ - { - "bead_id": f.bead_id, - "pr_number": f.pr_number, - "pr_title": f.pr_title, - "merged_at": f.merged_at, - "bead_status": f.bead_status, - } - for f in findings - ], - } - print(json.dumps(payload, indent=2)) - else: - print(_format_report(findings, prs_scanned=len(prs))) - - return 0 # advisory report, never fails a gate - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/docs/devtools.md b/docs/devtools.md index e84997fa6f..297f693b67 100644 --- a/docs/devtools.md +++ b/docs/devtools.md @@ -57,7 +57,6 @@ They are not a proof ledger or end-user archive workflow. | `devtools lab policy schema-versioning` | Enforce the policy boundary documented in docs/internals.md § 'Schema Versioning Model'. Durable tiers use explicit additive migrations with a backup gate; derived tiers are rebuilt or blue-green replaced from source evidence. | | `devtools lab policy timestamp-doctrine` | Enforce the time doctrine (UTC epoch-ms canon, docs/internals.md) at DDL-review time (cpf.1): a TEXT timestamp in source.db/user.db re-introduces tz-unknown ambiguity and lexicographic-vs-temporal sort divergence, and durable tiers need an explicit additive migration to fix later -- catching it before merge is orders cheaper than a copy-forward migration after. | | `devtools lab provider completeness` | Inspect detector, parser, fixture, schema, docs, ImportExplain, and caveat coverage before claiming a provider/importer mode is product-ready. | -| `devtools lab probe bead-pr-reconciliation` | After a merge-heavy stretch (a Workflow campaign, a merge train, or just several PRs landed close together), check for beads left open by a PR that referenced them -- catches the reconciliation gap where workers/agents are barred from closing beads themselves and no follow-up pass ever ran (2026-07-14: a 55-bead campaign left every bead open despite ~20 PRs merging clean). Advisory only -- reports candidates for a human/agent AC check, never auto-closes and never fails a gate. | | `devtools lab probe capture-regression` | Turn a live or probe failure JSON summary into a replayable local regression artifact. | | `devtools lab probe cost-reconciliation` | Validate archive token accounting against optional local Codex state_5.sqlite and Claude stats-cache.json before publishing cost or usage-analysis claims. | | `devtools lab probe pipeline` | Run real pipeline stages and optionally capture emitted summaries as regression cases. | @@ -182,7 +181,6 @@ Catalog bypass audit sites are machine-checked across workflow runs, CI-owned np | `devtools lab policy schema-versioning` | Verify durable-tier migration and derived-tier rebuild boundaries. | | `devtools lab policy table-exists-duplication` | Verify no module outside storage/introspection.py redefines table_exists/column_exists/index_exists. | | `devtools lab policy timestamp-doctrine` | Verify durable-tier DDL never stores a timestamp column as TEXT. | -| `devtools lab probe bead-pr-reconciliation` | Surface beads whose referenced PR merged but the bead is still open. | | `devtools lab probe capture-regression` | Capture pipeline-probe summaries as durable local regression cases. | | `devtools lab probe cost-reconciliation` | Reconcile Polylogue token accounting against private provider stores. | | `devtools lab probe pipeline` | Run typed pipeline probes against synthetic, staged, or archive-subset inputs. | @@ -283,6 +281,7 @@ Catalog bypass audit sites are machine-checked across workflow runs, CI-owned np | `devtools workspace merge` | Merge boundary wrapper: refuses `gh pr merge` without a fresh merge-gate receipt. | | `devtools workspace merge-conductor` | Mechanical-conflict triage for the PR merge train (dry-run by default). | | `devtools workspace merge-gate` | Structural pre-merge safety check: fresh local-verification receipt + no late review comments. | +| `devtools workspace pr-scope` | Render and validate the versioned PR Bead-scope carrier. | | `devtools workspace raw-append-chain-backfill-apply` | Promote membershipless append raws proven correct by live-source verification. | | `devtools workspace raw-authority-daemon-health-proof` | Prove daemon status/health HTTP responsiveness during a real raw-authority drain. | | `devtools workspace raw-authority-restart-proof` | Prove raw-authority crash recovery and conserved fixed-point convergence. | diff --git a/docs/tracker-authority.md b/docs/tracker-authority.md index 8884c3f0ef..713fde3651 100644 --- a/docs/tracker-authority.md +++ b/docs/tracker-authority.md @@ -76,6 +76,7 @@ Do not hand-edit `.beads/issues.jsonl` from a stale branch to perform this recon - A new Bead with a GitHub projection should receive exactly one tracker-relation label. - Closing a mirrored GitHub issue requires inspecting the corresponding Bead. - Closing a public-parent issue requires aggregate acceptance proof, not merely closed children. +- A non-draft PR carries a versioned `pr-scope` record naming every assigned Bead, its whole-Bead disposition, typed evidence refs, and any open successor for residual work. CI and the merge boundary validate that record against the current head and canonical Bead records; they do not parse acceptance prose. - A merged PR is evidence for a Bead, not automatic proof that its acceptance criteria are satisfied. - When a Bead supersedes GitHub solution wording, update GitHub before treating the issue body as an implementation plan. - Incident Beads should normally remain internal and link upward to a public trust/performance outcome rather than spawning a public issue per incident. diff --git a/tests/unit/devtools/test_merge_boundary.py b/tests/unit/devtools/test_merge_boundary.py index 877c0c7792..bb6c8c075e 100644 --- a/tests/unit/devtools/test_merge_boundary.py +++ b/tests/unit/devtools/test_merge_boundary.py @@ -9,7 +9,42 @@ import pytest -from devtools import merge_boundary, merge_gate +from devtools import merge_boundary, merge_gate, pr_scope + +_SCOPE_BEAD = { + "_type": "issue", + "id": "polylogue-test-scope", + "title": "test scope", + "description": "test record", + "acceptance_criteria": "Opaque acceptance prose.", + "status": "open", +} + + +@pytest.fixture(autouse=True) +def _scope_bead_record(tmp_path: Path) -> None: + beads_dir = tmp_path / ".beads" + beads_dir.mkdir() + (beads_dir / "issues.jsonl").write_text(json.dumps(_SCOPE_BEAD) + "\n") + + +def _scope_body(head_sha: str) -> str: + carrier = { + "version": 1, + "head_sha": head_sha, + "assigned_beads": ["polylogue-test-scope"], + "beads_digest": pr_scope.canonical_beads_digest({_SCOPE_BEAD["id"]: _SCOPE_BEAD}, ["polylogue-test-scope"]), + "dispositions": [ + { + "bead_id": "polylogue-test-scope", + "disposition": "satisfied", + "evidence": [{"kind": "test", "ref": "tests/unit/devtools/test_merge_boundary.py"}], + "successors": [], + } + ], + } + carrier["scope_digest"] = pr_scope.carrier_digest(carrier) + return pr_scope.render_carrier(carrier) def _base_pr_view(head_sha: str = "abc123", title: str = "fix: thing (#42)", state: str = "OPEN") -> dict[str, object]: @@ -20,6 +55,8 @@ def _base_pr_view(head_sha: str = "abc123", title: str = "fix: thing (#42)", sta "state": state, "mergeStateStatus": "CLEAN", "commits": [{"oid": head_sha, "committedDate": "2026-08-01T12:00:00Z"}], + "body": _scope_body(head_sha), + "isDraft": False, } @@ -149,6 +186,27 @@ def test_merge_refuses_when_pr_not_open(monkeypatch: pytest.MonkeyPatch, tmp_pat assert exit_code == 1 +def test_merge_refuses_when_pr_scope_carrier_is_missing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + pr_view = _base_pr_view() + pr_view["body"] = "## Summary\n\nNo carrier." + monkeypatch.setattr(subprocess, "run", _fake_run(pr_view)) + + exit_code = merge_boundary.cmd_merge( + 42, + command="devtools test x", + max_age_s=3600, + poll_rounds=1, + poll_interval_s=0, + dry_run=False, + with_verify=False, + verify_command="devtools verify --all", + ) + + assert exit_code == 2 + assert merge_boundary._read_ledger()["merges"] == [] + + def test_merge_refuses_when_late_unacked_review_comment_exists(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.chdir(tmp_path) pr_view = _base_pr_view() diff --git a/tests/unit/devtools/test_merge_gate.py b/tests/unit/devtools/test_merge_gate.py index 8219bf584f..7adc6bca34 100644 --- a/tests/unit/devtools/test_merge_gate.py +++ b/tests/unit/devtools/test_merge_gate.py @@ -8,9 +8,44 @@ import pytest -from devtools import merge_gate +from devtools import merge_gate, pr_scope from tests.infra.frozen_clock import FrozenClock +_SCOPE_BEAD = { + "_type": "issue", + "id": "polylogue-test-scope", + "title": "test scope", + "description": "test record", + "acceptance_criteria": "Opaque acceptance prose.", + "status": "open", +} + + +@pytest.fixture(autouse=True) +def _scope_bead_record(tmp_path: Path) -> None: + beads_dir = tmp_path / ".beads" + beads_dir.mkdir() + (beads_dir / "issues.jsonl").write_text(json.dumps(_SCOPE_BEAD) + "\n") + + +def _scope_body(head_sha: str) -> str: + carrier = { + "version": 1, + "head_sha": head_sha, + "assigned_beads": ["polylogue-test-scope"], + "beads_digest": pr_scope.canonical_beads_digest({_SCOPE_BEAD["id"]: _SCOPE_BEAD}, ["polylogue-test-scope"]), + "dispositions": [ + { + "bead_id": "polylogue-test-scope", + "disposition": "satisfied", + "evidence": [{"kind": "test", "ref": "tests/unit/devtools/test_merge_gate.py"}], + "successors": [], + } + ], + } + carrier["scope_digest"] = pr_scope.carrier_digest(carrier) + return pr_scope.render_carrier(carrier) + def _fake_run( pr_view: dict[str, object], @@ -26,6 +61,8 @@ def _fake_run( empty here -- covered separately in the normalization tests below.""" comment_rounds: list[list[dict[str, object]]] = poll_rounds if poll_rounds is not None else [comments] call_count = {"round": 0} + pr_view.setdefault("body", _scope_body(str(pr_view["headRefOid"]))) + pr_view.setdefault("isDraft", False) def _run(cmd: list[str], **kwargs: object) -> MagicMock: joined = " ".join(cmd) @@ -61,6 +98,7 @@ def test_record_persists_receipt_keyed_to_current_head_sha(monkeypatch: pytest.M assert exit_code == 0 receipt = json.loads(merge_gate._receipt_path(42).read_text()) assert receipt["head_sha"] == "abc123" + assert receipt["pr_scope_digest"] assert receipt["exit_code"] == 0 assert receipt["skips_tests"] is False diff --git a/tests/unit/devtools/test_pr_scope.py b/tests/unit/devtools/test_pr_scope.py new file mode 100644 index 0000000000..3dc5ad9f93 --- /dev/null +++ b/tests/unit/devtools/test_pr_scope.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from devtools import pr_scope + +HEAD_SHA = "a" * 40 +ASSIGNED = "polylogue-assigned" +OTHER_ASSIGNED = "polylogue-other-assigned" +OPEN_SUCCESSOR = "polylogue-open-successor" +CLOSED_SUCCESSOR = "polylogue-closed-successor" + + +def _record(bead_id: str, status: str = "open", *, title: str | None = None) -> dict[str, object]: + return { + "_type": "issue", + "id": bead_id, + "title": title or bead_id, + "description": "test record", + "acceptance_criteria": "This prose is deliberately opaque to pr_scope.", + "status": status, + "updated_at": "2026-08-06T00:00:00Z", + } + + +@pytest.fixture +def beads_path(tmp_path: Path) -> Path: + path = tmp_path / "issues.jsonl" + records = [_record(ASSIGNED), _record(OTHER_ASSIGNED), _record(OPEN_SUCCESSOR), _record(CLOSED_SUCCESSOR, "closed")] + path.write_text("\n".join(json.dumps(record) for record in records) + "\n") + return path + + +def _input(disposition: str = "satisfied", successors: list[str] | None = None) -> dict[str, object]: + return { + "assigned_beads": [ASSIGNED], + "dispositions": [ + { + "bead_id": ASSIGNED, + "disposition": disposition, + "evidence": [{"kind": "test", "ref": "tests/unit/devtools/test_pr_scope.py"}], + "successors": successors or [], + } + ], + } + + +def _body(input_payload: dict[str, object], beads_path: Path, *, head_sha: str = HEAD_SHA) -> str: + carrier = pr_scope.build_carrier(input_payload, head_sha=head_sha, beads_path=beads_path) + return f"## Summary\n\nStructured scope test.\n\n{pr_scope.render_carrier(carrier)}\n" + + +def _check( + body: str, beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str], *, head_sha: str = HEAD_SHA +) -> str: + body_path = tmp_path / "pr-body.md" + body_path.write_text(body) + exit_code = pr_scope.main( + ["check", "--body-file", str(body_path), "--head-sha", head_sha, "--beads-path", str(beads_path)] + ) + output = capsys.readouterr().out + return f"{exit_code}\n{output}" + + +def test_rendered_carrier_passes_the_production_check_command( + beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + scope_input = tmp_path / "scope.json" + scope_input.write_text(json.dumps(_input())) + + assert ( + pr_scope.main(["render", "--input", str(scope_input), "--head-sha", HEAD_SHA, "--beads-path", str(beads_path)]) + == 0 + ) + rendered = capsys.readouterr().out + + assert _check(rendered, beads_path, tmp_path, capsys).startswith("0\npr-scope OK") + + +def test_check_rejects_carrier_bound_to_a_different_head_sha( + beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + result = _check(_body(_input(), beads_path, head_sha="b" * 40), beads_path, tmp_path, capsys) + + assert result.startswith("1\n") + assert "head_sha does not match" in result + + +def test_check_rejects_missing_assigned_bead_disposition( + beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + payload = _input() + payload["assigned_beads"] = [ASSIGNED, OTHER_ASSIGNED] + result = _check(_body(payload, beads_path), beads_path, tmp_path, capsys) + + assert result.startswith("1\n") + assert f"missing whole-Bead disposition(s): {OTHER_ASSIGNED}" in result + + +@pytest.mark.parametrize( + ("successor", "reason"), + [(CLOSED_SUCCESSOR, "is closed"), ("polylogue-does-not-exist", "is unknown")], +) +def test_check_rejects_closed_or_unknown_residual_successor( + successor: str, reason: str, beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + result = _check(_body(_input("partial", [successor]), beads_path), beads_path, tmp_path, capsys) + + assert result.startswith("1\n") + assert reason in result + + +def test_check_rejects_stale_canonical_beads_digest( + beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + body = _body(_input(), beads_path) + records = [json.loads(line) for line in beads_path.read_text().splitlines()] + records[0]["title"] = "changed after carrier render" + beads_path.write_text("\n".join(json.dumps(record) for record in records) + "\n") + + result = _check(body, beads_path, tmp_path, capsys) + + assert result.startswith("1\n") + assert "beads_digest is stale" in result + + +def test_check_rejects_partial_disposition_without_successor( + beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + result = _check(_body(_input("partial"), beads_path), beads_path, tmp_path, capsys) + + assert result.startswith("1\n") + assert "partial disposition requires a named successor" in result + + +def test_check_rejects_pr_body_without_carrier( + beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + result = _check("## Summary\n\nNo structured carrier.\n", beads_path, tmp_path, capsys) + + assert result.startswith("1\n") + assert "missing the structured pr-scope carrier" in result From a03b02a626403a639505630a015b0db5e34264a8 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 6 Aug 2026 08:41:32 +0200 Subject: [PATCH 02/10] fix(devtools): bind CI scope checks to base validator Problem: the initial structured PR-scope gate ran the validator from the pull request checkout, allowed CircleCI builds without a pull-request URL to fail before validation, and left merge receipts weaker than the carrier they certified. What changed: resolve pull-request metadata from GitHub REST using the exact checkout head, run the validator from the base revision when available, validate the carrier schema strictly, bind receipts to scope and Bead digests, and make the merge use a matching head commit. Remove the prose-parsing PR state workflow and document the structured CI path. Verification: mypy; devtools test tests/unit/devtools/test_pr_scope.py tests/unit/devtools/test_merge_gate.py tests/unit/devtools/test_merge_boundary.py; devtools verify --quick; git diff --check. Co-Authored-By: Claude --- .circleci/config.yml | 17 +- .claude/agents/lane.md | 9 + .github/workflows/pr-state-guard.yml | 71 ----- CLAUDE.md | 3 + CONTRIBUTING.md | 2 +- devtools/command_catalog.py | 1 + devtools/merge_boundary.py | 12 +- devtools/merge_gate.py | 10 + devtools/pr_scope.py | 337 +++++++++++++++++++-- tests/unit/devtools/test_merge_boundary.py | 2 + tests/unit/devtools/test_merge_gate.py | 30 ++ tests/unit/devtools/test_pr_scope.py | 171 ++++++++++- 12 files changed, 565 insertions(+), 100 deletions(-) delete mode 100644 .github/workflows/pr-state-guard.yml diff --git a/.circleci/config.yml b/.circleci/config.yml index d7b9a9a5bf..7b257d709b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -74,14 +74,17 @@ jobs: - run: name: Structured PR scope carrier command: | - if [ -z "${CIRCLE_PULL_REQUEST:-}" ]; then - if [ "${CIRCLE_BRANCH:-}" = "master" ]; then - exit 0 - fi - echo "quick-gate requires CIRCLE_PULL_REQUEST for every non-master build" >&2 - exit 1 + if [ "${CIRCLE_BRANCH:-}" = "master" ]; then + exit 0 fi - ~/.local/bin/uv run devtools workspace pr-scope check --pr "${CIRCLE_PULL_REQUEST##*/}" + PR_ARGS=() + if [ -n "${CIRCLE_PULL_REQUEST:-}" ]; then + PR_ARGS=(--pr "${CIRCLE_PULL_REQUEST##*/}") + fi + ~/.local/bin/uv run devtools workspace pr-scope check-ci \ + "${PR_ARGS[@]}" \ + --repo "${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" \ + --expected-head-sha "${CIRCLE_SHA1}" - run: name: devtools verify --quick command: ~/.local/bin/uv run devtools verify --quick diff --git a/.claude/agents/lane.md b/.claude/agents/lane.md index af0d26373f..7d1b29ce1f 100644 --- a/.claude/agents/lane.md +++ b/.claude/agents/lane.md @@ -109,6 +109,15 @@ Open a PR (branch off `master`, conventional commit-style subject, rejected if there was a real fork. - **Verification** — the exact commands you ran and the output line that matters, not "tests pass". +- **Bead disposition matrix** — one whole-Bead disposition per assigned ID, + typed evidence refs, and an existing open successor for every residual + outcome. + +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. Reference any bead with neutral wording only (`Ref polylogue-xxxx` / `Ref #N`). **Never use GitHub resolver keywords** (closes/fixes/resolves) diff --git a/.github/workflows/pr-state-guard.yml b/.github/workflows/pr-state-guard.yml deleted file mode 100644 index 62e7ee28d9..0000000000 --- a/.github/workflows/pr-state-guard.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: PR State Guard - -on: - pull_request: - branches: [master] - types: - - opened - - edited - - reopened - - synchronize - - ready_for_review - -permissions: - # Read repository metadata for this checkout-free PR workflow. - contents: read - # Read pull request metadata exposed through the event payload. - pull-requests: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - issue-state-keywords: - name: reject issue state keywords - timeout-minutes: 3 - runs-on: ubuntu-latest - steps: - - name: Check PR title and body - run: | - python3 - <<'PY' - import json - import os - import re - import sys - - event_path = os.environ.get("GITHUB_EVENT_PATH") - if not event_path: - print("Error: GITHUB_EVENT_PATH is not set") - sys.exit(1) - try: - with open(event_path, encoding="utf-8") as handle: - event = json.load(handle) - except FileNotFoundError: - print(f"Error: GitHub event payload not found: {event_path}") - sys.exit(1) - except json.JSONDecodeError as exc: - print(f"Error: GitHub event payload is not valid JSON: {exc}") - sys.exit(1) - - pr = event.get("pull_request") or {} - fields = { - "title": pr.get("title") or "", - "body": pr.get("body") or "", - } - pattern = re.compile( - r"\b(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?)\s*:?\s+" - r"(?:(?:[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#\d+\b|" - r"https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/issues/\d+\b)", - re.IGNORECASE, - ) - hits = [(name, match.group(0)) for name, text in fields.items() for match in pattern.finditer(text)] - if hits: - print("PR title/body contains GitHub issue-state keywords next to issue refs.") - print("Use neutral references such as 'Ref #NNN' and describe remaining scope explicitly.") - for field, hit in hits: - print(f"- {field}: {hit!r}") - sys.exit(1) - - print("PR state guard: no issue-state keywords found") - PY diff --git a/CLAUDE.md b/CLAUDE.md index 80e1f5affc..6c7abf2fc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -384,6 +384,9 @@ workflow, not optional conveniences — use them at the point named, every time: `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. + 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. - **Immediately after spawning a worktree-isolated lane, not after it reports back**: `devtools workspace verify-worktree --expect-branch ` — confirms the worktree is real and isolated before the lane has diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb97011317..4698fb1520 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -219,7 +219,7 @@ The repository should stay aligned with the workflow above: - protect `master` against direct pushes - require pull requests for normal changes -- require the `CI`, `Nix`, and `PR State Guard` checks before merge +- require the authoritative CI checks before merge - keep squash merge enabled and leave merge-commit and rebase-merge disabled - enable automatic deletion of head branches after merge - allow Update branch for stale PRs diff --git a/devtools/command_catalog.py b/devtools/command_catalog.py index f1990df59e..7ba46beea1 100644 --- a/devtools/command_catalog.py +++ b/devtools/command_catalog.py @@ -782,6 +782,7 @@ class CatalogBypassSite: 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 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)", ), ), diff --git a/devtools/merge_boundary.py b/devtools/merge_boundary.py index ab3479e514..3b0e05890e 100644 --- a/devtools/merge_boundary.py +++ b/devtools/merge_boundary.py @@ -197,7 +197,17 @@ def cmd_merge( return 0 merge_result = subprocess.run( - ["gh", "pr", "merge", str(pr), "--squash", "--subject", clean_title], + [ + "gh", + "pr", + "merge", + str(pr), + "--squash", + "--match-head-commit", + head_sha, + "--subject", + clean_title, + ], capture_output=True, text=True, timeout=120, diff --git a/devtools/merge_gate.py b/devtools/merge_gate.py index b55ce69fb3..02b96990ed 100644 --- a/devtools/merge_gate.py +++ b/devtools/merge_gate.py @@ -461,6 +461,16 @@ def cmd_check( verdict.reasons.append( "receipt pr_scope_digest does not match the current carrier -- re-record after scope changes" ) + if receipt.get("pr_scope_beads_digest") != scope.beads_digest: + verdict.ok = False + verdict.reasons.append( + "receipt pr_scope_beads_digest does not match the current canonical Bead records -- re-record" + ) + if receipt.get("pr_scope_assigned_beads") != scope.assigned_beads: + verdict.ok = False + verdict.reasons.append( + "receipt pr_scope_assigned_beads does not match the current carrier -- 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 4b20a983fc..955ec8faca 100644 --- a/devtools/pr_scope.py +++ b/devtools/pr_scope.py @@ -12,20 +12,50 @@ import argparse import hashlib import json +import os +import re import subprocess import sys +import tempfile from dataclasses import asdict, dataclass, field +from enum import StrEnum from pathlib import Path from typing import Any +from urllib import error, parse, request _CARRIER_PREFIX = "polylogue-pr-scope:v1" _CARRIER_START = f"" _VERSION = 1 _BEADS_PATH = Path(".beads/issues.jsonl") -_DISPOSITIONS = frozenset({"satisfied", "partial", "deferred", "superseded"}) -_RESIDUAL_DISPOSITIONS = frozenset({"partial", "deferred", "superseded"}) -_EVIDENCE_KINDS = frozenset({"command", "commit", "diff", "receipt", "review", "test"}) +_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"}) +_DISPOSITION_KEYS = frozenset({"bead_id", "disposition", "evidence", "successors"}) +_EVIDENCE_KEYS = frozenset({"kind", "ref"}) + + +class ScopeDisposition(StrEnum): + SATISFIED = "satisfied" + PARTIAL = "partial" + DEFERRED = "deferred" + SUPERSEDED = "superseded" + + +class EvidenceKind(StrEnum): + COMMAND = "command" + COMMIT = "commit" + DIFF = "diff" + RECEIPT = "receipt" + REVIEW = "review" + TEST = "test" + + +_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) @dataclass(frozen=True, slots=True) @@ -37,6 +67,14 @@ class ScopeVerdict: assigned_beads: list[str] = field(default_factory=list) +@dataclass(frozen=True, slots=True) +class PullRequestMetadata: + body: str + head_sha: str + base_sha: str + is_draft: bool + + def _canonical_json(value: object) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -101,6 +139,22 @@ def extract_carrier(body: str) -> tuple[dict[str, Any] | None, list[str]]: return carrier, [] +def _validate_keys( + value: dict[str, Any], + *, + label: str, + allowed: frozenset[str], + required: frozenset[str], + reasons: list[str], +) -> None: + unknown = sorted(set(value) - allowed) + missing = sorted(required - set(value)) + if unknown: + reasons.append(f"{label} has unknown field(s): {', '.join(unknown)}") + if missing: + reasons.append(f"{label} is missing required field(s): {', '.join(missing)}") + + def validate_carrier( carrier: dict[str, Any], *, @@ -109,6 +163,13 @@ def validate_carrier( beads_path: Path = _BEADS_PATH, ) -> ScopeVerdict: reasons: list[str] = [] + _validate_keys( + carrier, + label="carrier", + allowed=_CARRIER_KEYS, + required=_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") @@ -152,6 +213,13 @@ def validate_carrier( 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}") @@ -172,6 +240,14 @@ def validate_carrier( 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 @@ -236,6 +312,9 @@ def build_carrier(input_payload: dict[str, Any], *, head_sha: str, beads_path: P raise ValueError("input assigned_beads must be a list of Bead IDs") carrier["beads_digest"] = canonical_beads_digest(load_bead_records(beads_path), assigned) carrier["scope_digest"] = carrier_digest(carrier) + verdict = validate_carrier(carrier, head_sha=head_sha, is_draft=False, beads_path=beads_path) + if not verdict.ok: + raise ValueError("invalid scope input: " + "; ".join(verdict.reasons)) return carrier @@ -244,15 +323,209 @@ def _git_head_sha() -> str: return result.stdout.strip() -def _pr_body(pr: int) -> tuple[str, str, bool]: - result = subprocess.run( - ["gh", "pr", "view", str(pr), "--json", "body,headRefOid,isDraft"], +def _repository_from_remote(remote: str) -> str | None: + value = remote.strip() + if value.startswith("git@github.com:"): + value = value.removeprefix("git@github.com:") + elif "github.com/" in value: + value = value.split("github.com/", 1)[1] + else: + return None + value = value.removesuffix(".git").strip("/") + return value if _REPOSITORY_PATTERN.fullmatch(value) else None + + +def resolve_repository(explicit: str | None = None) -> str: + candidates = [ + explicit, + os.environ.get("GITHUB_REPOSITORY"), + ( + f"{os.environ['CIRCLE_PROJECT_USERNAME']}/{os.environ['CIRCLE_PROJECT_REPONAME']}" + if os.environ.get("CIRCLE_PROJECT_USERNAME") and os.environ.get("CIRCLE_PROJECT_REPONAME") + else None + ), + ] + for candidate in candidates: + if candidate and _REPOSITORY_PATTERN.fullmatch(candidate): + return candidate + remote = subprocess.run( + ["git", "remote", "get-url", "origin"], capture_output=True, text=True, - check=True, + check=False, ) - payload = json.loads(result.stdout) - return payload.get("body") or "", payload["headRefOid"], bool(payload.get("isDraft")) + if remote.returncode == 0: + repository = _repository_from_remote(remote.stdout) + if repository: + return repository + raise ValueError("cannot resolve GitHub repository; pass --repo OWNER/REPO") + + +def _github_request_bytes( + path: str, + *, + accept: str = "application/vnd.github+json", + missing_ok: bool = False, +) -> bytes | None: + api_url = os.environ.get("GITHUB_API_URL", _GITHUB_API_URL).rstrip("/") + url = f"{api_url}/{path.lstrip('/')}" + headers = { + "Accept": accept, + "User-Agent": "polylogue-pr-scope", + "X-GitHub-Api-Version": "2022-11-28", + } + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + api_request = request.Request(url, headers=headers) + try: + with request.urlopen(api_request, timeout=30) as response: + payload = response.read() + if not isinstance(payload, bytes): + raise RuntimeError(f"GitHub API returned non-bytes content for {path}") + return payload + except error.HTTPError as exc: + if missing_ok and exc.code == 404: + return None + detail = exc.read().decode(errors="replace")[:300] + raise RuntimeError(f"GitHub API returned HTTP {exc.code} for {path}: {detail}") from exc + except error.URLError as exc: + raise RuntimeError(f"GitHub API request failed for {path}: {exc.reason}") from exc + + +def _pr_metadata_from_payload(payload: object) -> PullRequestMetadata: + if not isinstance(payload, dict): + raise ValueError("GitHub PR response must be an object") + head = payload.get("head") + base = payload.get("base") + if not isinstance(head, dict) or not isinstance(head.get("sha"), str): + raise ValueError("GitHub PR response is missing head.sha") + if not isinstance(base, dict) or not isinstance(base.get("sha"), str): + raise ValueError("GitHub PR response is missing base.sha") + return PullRequestMetadata( + body=payload.get("body") or "", + head_sha=head["sha"], + base_sha=base["sha"], + is_draft=bool(payload.get("draft")), + ) + + +def fetch_pr_metadata(pr: int, *, repository: str) -> PullRequestMetadata: + raw = _github_request_bytes(f"repos/{repository}/pulls/{pr}") + if raw is None: + raise RuntimeError(f"GitHub API returned no metadata for PR #{pr}") + return _pr_metadata_from_payload(json.loads(raw)) + + +def fetch_pr_for_head(*, repository: str, head_sha: str) -> tuple[int, PullRequestMetadata]: + raw = _github_request_bytes(f"repos/{repository}/commits/{head_sha}/pulls") + if raw is None: + raise RuntimeError(f"GitHub API returned no PR metadata for head {head_sha[:8]}") + payload = json.loads(raw) + if not isinstance(payload, list): + raise ValueError("GitHub commit-pulls response must be a list") + candidates: list[tuple[int, PullRequestMetadata]] = [] + for item in payload: + if not isinstance(item, dict) or item.get("state") != "open" or not isinstance(item.get("number"), int): + continue + metadata = _pr_metadata_from_payload(item) + if metadata.head_sha == head_sha: + candidates.append((item["number"], metadata)) + if len(candidates) != 1: + raise ValueError(f"expected one open PR for head {head_sha[:8]}, found {len(candidates)}") + return candidates[0] + + +def fetch_base_validator_source(*, repository: str, base_sha: str) -> bytes | None: + path = f"repos/{repository}/contents/devtools/pr_scope.py?ref={parse.quote(base_sha, safe='')}" + return _github_request_bytes(path, accept="application/vnd.github.raw+json", missing_ok=True) + + +def _emit_verdict(verdict: ScopeVerdict, *, head_sha: str, as_json: bool) -> int: + if as_json: + print(json.dumps(asdict(verdict), indent=2)) + elif verdict.ok: + print(f"pr-scope OK @ {head_sha[:8]}: {', '.join(verdict.assigned_beads)}") + else: + print(f"pr-scope BLOCK @ {head_sha[:8]}:") + for reason in verdict.reasons: + print(f" - {reason}") + return 0 if verdict.ok else 1 + + +def _run_validator_source( + source: bytes, + *, + metadata: PullRequestMetadata, + beads_path: Path, +) -> int: + with tempfile.TemporaryDirectory(prefix="polylogue-pr-scope-base-") as temporary: + root = Path(temporary) + validator_path = root / "pr_scope.py" + body_path = root / "pr-body.md" + validator_path.write_bytes(source) + body_path.write_text(metadata.body, encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + str(validator_path), + "check", + "--body-file", + str(body_path), + "--head-sha", + metadata.head_sha, + "--beads-path", + str(beads_path.resolve()), + ], + capture_output=True, + text=True, + check=False, + ) + sys.stdout.write(result.stdout) + sys.stderr.write(result.stderr) + return result.returncode + + +def check_ci_metadata( + metadata: PullRequestMetadata, + *, + repository: str, + beads_path: Path, + checkout_head_sha: str, + expected_head_sha: str | None, +) -> int: + if checkout_head_sha != metadata.head_sha: + print( + f"REFUSING CI pr-scope check: checkout HEAD {checkout_head_sha[:8]} does not match " + f"PR head {metadata.head_sha[:8]}", + file=sys.stderr, + ) + return 2 + if expected_head_sha and expected_head_sha != metadata.head_sha: + print( + f"REFUSING CI pr-scope check: CI head {expected_head_sha[:8]} does not match " + f"PR head {metadata.head_sha[:8]}", + file=sys.stderr, + ) + return 2 + + base_source = fetch_base_validator_source(repository=repository, base_sha=metadata.base_sha) + if base_source is not None: + print(f"pr-scope CI authority: base revision {metadata.base_sha[:8]}") + return _run_validator_source(base_source, metadata=metadata, beads_path=beads_path) + + print( + f"pr-scope CI bootstrap: base revision {metadata.base_sha[:8]} has no validator; " + "using the checked-out validator for this first landing", + file=sys.stderr, + ) + verdict = validate_pr_body( + metadata.body, + head_sha=metadata.head_sha, + is_draft=metadata.is_draft, + beads_path=beads_path, + ) + return _emit_verdict(verdict, head_sha=metadata.head_sha, as_json=False) def main(argv: list[str] | None = None) -> int: @@ -268,10 +541,17 @@ def main(argv: list[str] | None = None) -> int: check_source = check.add_mutually_exclusive_group(required=True) check_source.add_argument("--pr", type=int, help="GitHub PR number to inspect") check_source.add_argument("--body-file", type=Path, help="PR body file for local validation") + check.add_argument("--repo", help="GitHub OWNER/REPO (default: CI metadata or origin remote)") check.add_argument("--head-sha", help="required with --body-file") check.add_argument("--beads-path", type=Path, default=_BEADS_PATH) check.add_argument("--json", action="store_true", dest="as_json") + check_ci = sub.add_parser("check-ci", help="validate with the PR base revision's authoritative checker") + check_ci.add_argument("--pr", type=int, help="GitHub PR number (default: resolve from --expected-head-sha)") + check_ci.add_argument("--repo", required=True, help="GitHub OWNER/REPO from CI metadata") + check_ci.add_argument("--expected-head-sha", default=os.environ.get("CIRCLE_SHA1")) + check_ci.add_argument("--beads-path", type=Path, default=_BEADS_PATH) + args = parser.parse_args(argv) if args.action == "render": try: @@ -285,9 +565,36 @@ def main(argv: list[str] | None = None) -> int: print(render_carrier(carrier)) return 0 + if args.action == "check-ci": + try: + repository = resolve_repository(args.repo) + if args.pr is not None: + pr_number = args.pr + metadata = fetch_pr_metadata(args.pr, repository=repository) + else: + if not args.expected_head_sha: + raise ValueError("--pr or --expected-head-sha is required") + pr_number, metadata = fetch_pr_for_head(repository=repository, head_sha=args.expected_head_sha) + print(f"pr-scope CI metadata: resolved PR #{pr_number} from head {args.expected_head_sha[:8]}") + checkout_head_sha = _git_head_sha() + return check_ci_metadata( + metadata, + repository=repository, + beads_path=args.beads_path, + checkout_head_sha=checkout_head_sha, + expected_head_sha=args.expected_head_sha, + ) + except (OSError, ValueError, json.JSONDecodeError, RuntimeError, subprocess.SubprocessError) as exc: + print(f"REFUSING CI pr-scope check: {exc}", file=sys.stderr) + return 2 + try: if args.pr is not None: - body, head_sha, is_draft = _pr_body(args.pr) + repository = resolve_repository(args.repo) + metadata = fetch_pr_metadata(args.pr, repository=repository) + body = metadata.body + head_sha = metadata.head_sha + is_draft = metadata.is_draft else: if not args.head_sha: raise ValueError("--head-sha is required with --body-file") @@ -299,15 +606,7 @@ def main(argv: list[str] | None = None) -> int: print(f"REFUSING to check pr-scope carrier: {exc}", file=sys.stderr) return 2 - if args.as_json: - print(json.dumps(asdict(verdict), indent=2)) - elif verdict.ok: - print(f"pr-scope OK @ {head_sha[:8]}: {', '.join(verdict.assigned_beads)}") - else: - print(f"pr-scope BLOCK @ {head_sha[:8]}:") - for reason in verdict.reasons: - print(f" - {reason}") - return 0 if verdict.ok else 1 + return _emit_verdict(verdict, head_sha=head_sha, as_json=args.as_json) if __name__ == "__main__": diff --git a/tests/unit/devtools/test_merge_boundary.py b/tests/unit/devtools/test_merge_boundary.py index bb6c8c075e..7fa06d6c87 100644 --- a/tests/unit/devtools/test_merge_boundary.py +++ b/tests/unit/devtools/test_merge_boundary.py @@ -165,6 +165,8 @@ def _run(cmd: list[str], **kwargs: Any) -> MagicMock: assert exit_code == 0 subject_index = captured["cmd"].index("--subject") + 1 assert captured["cmd"][subject_index] == "fix: thing (#42)" + match_index = captured["cmd"].index("--match-head-commit") + 1 + assert captured["cmd"][match_index] == "abc123" def test_merge_refuses_when_pr_not_open(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: diff --git a/tests/unit/devtools/test_merge_gate.py b/tests/unit/devtools/test_merge_gate.py index 7adc6bca34..93ea9906d8 100644 --- a/tests/unit/devtools/test_merge_gate.py +++ b/tests/unit/devtools/test_merge_gate.py @@ -198,6 +198,36 @@ def test_check_ok_when_receipt_fresh_and_matches_head_with_no_late_comments( assert exit_code == 0 +@pytest.mark.parametrize( + ("receipt_field", "mutated_value", "reason"), + [ + ("pr_scope_beads_digest", "stale", "pr_scope_beads_digest"), + ("pr_scope_assigned_beads", ["polylogue-other"], "pr_scope_assigned_beads"), + ], +) +def test_check_blocks_when_receipt_scope_components_are_mutated( + receipt_field: str, + mutated_value: object, + reason: str, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.chdir(tmp_path) + pr_view = _base_pr_view() + _record(monkeypatch, pr_view) + receipt_path = merge_gate._receipt_path(42) + receipt = json.loads(receipt_path.read_text()) + receipt[receipt_field] = mutated_value + receipt_path.write_text(json.dumps(receipt)) + + monkeypatch.setattr(subprocess, "run", _fake_run(pr_view, [])) + exit_code = merge_gate.cmd_check(42, max_age_s=3600, poll_rounds=1, poll_interval_s=0, as_json=False) + + assert exit_code == 1 + assert reason in capsys.readouterr().out + + def test_check_blocks_when_receipt_is_for_a_stale_sha(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.chdir(tmp_path) _record(monkeypatch, _base_pr_view(head_sha="abc123")) diff --git a/tests/unit/devtools/test_pr_scope.py b/tests/unit/devtools/test_pr_scope.py index 3dc5ad9f93..4b62b0723c 100644 --- a/tests/unit/devtools/test_pr_scope.py +++ b/tests/unit/devtools/test_pr_scope.py @@ -2,6 +2,8 @@ import json from pathlib import Path +from unittest.mock import MagicMock +from urllib import request import pytest @@ -49,7 +51,13 @@ def _input(disposition: str = "satisfied", successors: list[str] | None = None) def _body(input_payload: dict[str, object], beads_path: Path, *, head_sha: str = HEAD_SHA) -> str: - carrier = pr_scope.build_carrier(input_payload, head_sha=head_sha, beads_path=beads_path) + carrier = dict(input_payload) + carrier["version"] = 1 + carrier["head_sha"] = head_sha + assigned = carrier["assigned_beads"] + assert isinstance(assigned, list) + carrier["beads_digest"] = pr_scope.canonical_beads_digest(pr_scope.load_bead_records(beads_path), assigned) + carrier["scope_digest"] = pr_scope.carrier_digest(carrier) return f"## Summary\n\nStructured scope test.\n\n{pr_scope.render_carrier(carrier)}\n" @@ -65,6 +73,20 @@ def _check( return f"{exit_code}\n{output}" +class _FakeHttpResponse: + def __init__(self, payload: bytes) -> None: + self.payload = payload + + def __enter__(self) -> _FakeHttpResponse: + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self) -> bytes: + return self.payload + + def test_rendered_carrier_passes_the_production_check_command( beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -80,6 +102,126 @@ def test_rendered_carrier_passes_the_production_check_command( assert _check(rendered, beads_path, tmp_path, capsys).startswith("0\npr-scope OK") +def test_pr_check_uses_public_github_rest_without_cli_auth( + monkeypatch: pytest.MonkeyPatch, + beads_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + body = _body(_input(), beads_path) + requests: list[request.Request] = [] + + def _urlopen(api_request: request.Request, *, timeout: int) -> _FakeHttpResponse: + assert timeout == 30 + requests.append(api_request) + payload = { + "body": body, + "draft": False, + "head": {"sha": HEAD_SHA}, + "base": {"sha": "b" * 40}, + } + return _FakeHttpResponse(json.dumps(payload).encode()) + + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.setattr(request, "urlopen", _urlopen) + + exit_code = pr_scope.main(["check", "--pr", "42", "--repo", "Sinity/polylogue", "--beads-path", str(beads_path)]) + + assert exit_code == 0 + assert capsys.readouterr().out.startswith("pr-scope OK") + assert len(requests) == 1 + assert requests[0].full_url == "https://api.github.com/repos/Sinity/polylogue/pulls/42" + assert requests[0].get_header("Authorization") is None + + +def test_ci_resolves_pr_from_exact_head_when_circle_pr_url_is_absent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: list[request.Request] = [] + + def _urlopen(api_request: request.Request, *, timeout: int) -> _FakeHttpResponse: + assert timeout == 30 + requests.append(api_request) + payload = [ + { + "number": 3845, + "state": "open", + "body": "carrier", + "draft": False, + "head": {"sha": HEAD_SHA}, + "base": {"sha": "b" * 40}, + }, + { + "number": 3800, + "state": "closed", + "body": "old carrier", + "draft": False, + "head": {"sha": HEAD_SHA}, + "base": {"sha": "c" * 40}, + }, + ] + return _FakeHttpResponse(json.dumps(payload).encode()) + + monkeypatch.setattr(request, "urlopen", _urlopen) + + pr_number, metadata = pr_scope.fetch_pr_for_head(repository="Sinity/polylogue", head_sha=HEAD_SHA) + + assert pr_number == 3845 + assert metadata.head_sha == HEAD_SHA + assert requests[0].full_url == f"https://api.github.com/repos/Sinity/polylogue/commits/{HEAD_SHA}/pulls" + + +def test_ci_check_executes_base_revision_validator( + monkeypatch: pytest.MonkeyPatch, + beads_path: Path, +) -> None: + metadata = pr_scope.PullRequestMetadata( + body="## Summary\n\nA PR-modified validator would accept this body.", + head_sha=HEAD_SHA, + base_sha="b" * 40, + is_draft=False, + ) + base_source = Path(pr_scope.__file__).read_bytes() + current_validator = MagicMock(return_value=pr_scope.ScopeVerdict(ok=True)) + monkeypatch.setattr(pr_scope, "fetch_base_validator_source", lambda **_kwargs: base_source) + monkeypatch.setattr(pr_scope, "validate_pr_body", current_validator) + + exit_code = pr_scope.check_ci_metadata( + metadata, + repository="Sinity/polylogue", + beads_path=beads_path, + checkout_head_sha=HEAD_SHA, + expected_head_sha=HEAD_SHA, + ) + + assert exit_code == 1 + current_validator.assert_not_called() + + +def test_ci_check_bootstraps_once_when_base_has_no_validator( + monkeypatch: pytest.MonkeyPatch, + beads_path: Path, +) -> None: + metadata = pr_scope.PullRequestMetadata( + body=_body(_input(), beads_path), + head_sha=HEAD_SHA, + base_sha="b" * 40, + is_draft=False, + ) + monkeypatch.setattr(pr_scope, "fetch_base_validator_source", lambda **_kwargs: None) + + assert ( + pr_scope.check_ci_metadata( + metadata, + repository="Sinity/polylogue", + beads_path=beads_path, + checkout_head_sha=HEAD_SHA, + expected_head_sha=HEAD_SHA, + ) + == 0 + ) + + def test_check_rejects_carrier_bound_to_a_different_head_sha( beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -136,6 +278,33 @@ def test_check_rejects_partial_disposition_without_successor( assert "partial disposition requires a named successor" in result +def test_check_rejects_unknown_schema_fields( + beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + carrier = pr_scope.build_carrier(_input(), head_sha=HEAD_SHA, beads_path=beads_path) + carrier["acceptance_summary"] = "silently extending v1 would make the schema ambiguous" + carrier["scope_digest"] = pr_scope.carrier_digest(carrier) + + result = _check(pr_scope.render_carrier(carrier), beads_path, tmp_path, capsys) + + assert result.startswith("1\n") + assert "unknown field(s): acceptance_summary" in result + + +def test_render_refuses_invalid_partial_scope_input( + beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + scope_input = tmp_path / "scope.json" + scope_input.write_text(json.dumps(_input("partial"))) + + exit_code = pr_scope.main( + ["render", "--input", str(scope_input), "--head-sha", HEAD_SHA, "--beads-path", str(beads_path)] + ) + + assert exit_code == 2 + assert "partial disposition requires a named successor" in capsys.readouterr().err + + def test_check_rejects_pr_body_without_carrier( beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: From b5fe5291ab685815ed8875740636e3bd5f684bbf Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 6 Aug 2026 08:45:13 +0200 Subject: [PATCH 03/10] chore(beads): track immutable PR scope authority Record the structured CI authority follow-up and make it a prerequisite of the remaining merge-gate status-check bead. Co-Authored-By: Claude --- .beads/issues.jsonl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c760edbb98..5f55c0c41b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -144,6 +144,7 @@ {"_type":"issue","id":"polylogue-tf2.1","title":"Rerun forensics on current archive; price origin_reported providers","description":"Rerun scripts/agent_forensics.py against the current archive (v23+); price origin_reported providers via the vendored LiteLLM catalog (match last path segment); all-provider headline or explicitly-labeled per-provenance figures that cannot be misread; record deltas vs 06-27; verify chart SVGs render. Cache-inclusion must be disambiguated (Codex input INCLUDES cached ~96%; see bd memories). Also blocked on logical-session token attribution — the headline must not be double-counted.","notes":"Correction to close_reason monetary values: stored/provider-priced subset was $239,453.14; catalog API-equivalent was $318,650.88; origin_reported catalog estimate was $79,197.74. The original close_reason text lost dollar-prefixed digits due shell expansion, not measurement drift.","status":"closed","priority":0,"issue_type":"task","assignee":"Sinity","owner":"ezo.dev@gmail.com","created_at":"2026-07-03T04:31:33Z","created_by":"Sinity","updated_at":"2026-07-31T22:35:43Z","started_at":"2026-07-03T09:28:10Z","closed_at":"2026-07-03T09:59:02Z","close_reason":"Completed with blocker caveat preserved: scripts/agent_forensics.py now prices origin_reported rows through the shared vendored LiteLLM pricing catalog while preserving stored provenance; report separates stored/provider-priced cost from catalog API-equivalent estimates and carries logical-session/cache caveats instead of claiming final billing reconciliation. Regenerated current artifact at .agent/demos/agent-forensics against /home/sinity/.local/share/polylogue schema v23: 16,498 physical sessions, 4,142,175 messages, 356.5B tokens, ,453.14 stored/provider-priced subset, ,650.88 catalog API-equivalent, and ,197.74 origin_reported catalog estimate. SVG parse check passed for 9 charts; devtools test tests/unit/scripts/test_agent_forensics.py passed; devtools verify --quick passed run 20260703T095718Z-quick-753466-96559776; devloop-review clean. Remaining final-reconciliation blocker stays open as polylogue-4ts.2.","labels":["area:usage","campaign"],"dependencies":[{"issue_id":"polylogue-tf2.1","depends_on_id":"polylogue-4ts.2","type":"blocks","created_at":"2026-07-03T06:32:45Z","created_by":"Sinity","metadata":"{}"},{"issue_id":"polylogue-tf2.1","depends_on_id":"polylogue-sru.7","type":"blocks","created_at":"2026-07-03T06:31:33Z","created_by":"Sinity","metadata":"{}"},{"issue_id":"polylogue-tf2.1","depends_on_id":"polylogue-tf2","type":"parent-child","created_at":"2026-07-03T06:31:33Z","created_by":"Sinity","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"polylogue-tf2","title":"Campaign: agent-forensics regeneration + all-provider repricing","description":"Regenerate the agent-forensics packet on the current archive with an honest all-provider headline. The 2026-06-27 report (546.6B tokens, $89,368 API-list equivalent, 216x cache amplification) is the most stranger-legible artifact on any shelf, but its numbers are pre-dedup stale and the headline prices only the priced-provenance subset (Claude Code cost_usd rows); Codex/ChatGPT/Gemini are origin_reported token counts with no dollar value (operator estimate ~$150K all-provider). Sequenced after claim-vs-evidence per operator direction 2026-07-02.","design":"Current slice design: turn the existing agent-forensics/cost headline into a product-backed all-provider repricing artifact. First inspect devtools/scripts and polylogue analyze surfaces for agent_forensics/cost code. Use active archive usage headline (detail=headline) for authoritative physical_session and logical_session_model_high_water token totals. Keep priced-provenance dollars and origin-reported token estimates separate: do not multiply every token by one blended price without a labeled lane. Add or reuse a shared pricing/projection helper so the demo artifact is regenerated from Polylogue product code, not ad hoc SQL. Acceptance for this slice: the generated agent-forensics artifact names archive root/schema, includes physical vs logical token grain, separates priced subset from origin-reported estimate lanes, gives reproduction commands, and has focused tests for any new repricing helper/surface.","acceptance_criteria":"Terminal state: regenerated forensics packet on the current archive with an honest all-provider headline (priced subset AND origin-reported estimate lanes separated), agent_forensics.py folded into polylogue analyze (tf2.2), artifact on the demo shelf with reproduction commands, cold-reader gate passed. Epic closes only when that artifact is recorded.","status":"closed","priority":0,"issue_type":"epic","assignee":"Sinity","owner":"ezo.dev@gmail.com","created_at":"2026-07-03T04:31:32Z","created_by":"Sinity","updated_at":"2026-07-31T22:35:43Z","started_at":"2026-07-03T18:47:23Z","closed_at":"2026-07-03T19:06:44Z","close_reason":"Completed: provider usage headline now exposes product-backed pricing lanes in polylogue analyze usage --detail headline, separating stored/provider-priced cost from catalog API-equivalent estimates for origin_reported rows. Regenerated the current .agent/demos/agent-forensics artifact against /home/sinity/.local/share/polylogue schema v23: physical-session tokens 395,320,980,423; logical high-water tokens 288,741,229,728; stored/provider-priced USD 243,392.189328; catalog API-equivalent USD 337,565.031618; priced lane 13,889 rows / 12,331 sessions / 12,650 matched rows; origin_reported lane 2,308 rows / 2,270 sessions / 2,302 matched rows. Verification: live polylogue --plain analyze usage --detail headline --format json --limit 0 wrote /realm/tmp/polylogue-usage-headline-pricing-current.json; devtools test tests/unit/storage/test_provider_usage_report.py tests/unit/cli/test_diagnostics.py passed 23 tests; devtools verify --quick passed run 20260703T190553Z-quick-2226137-d91d4e8f; devtools workspace demo-shelf --json reported ok. Non-claim preserved: this is not final billing reconciliation and physical/logical token grains stay explicitly separated.","labels":["area:usage","campaign","size:M","spine"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"polylogue-sru","title":"Campaign: claim-vs-evidence report to finding-grade","description":"Terminal state: an externally publishable finding ('how often do coding agents proceed past failed tool calls, by model/tool') with stated sample frame, calibrated markers, benign/consequential split, seeded stranger-runnable reproduction, and a passed cold-reader gate. Slice closure is NOT campaign closure; this epic stays top-of-frame until its terminal state is recorded.\\n\\nState as of 2026-07-03 after calibrated active-archive regeneration: archive root /home/sinity/.local/share/polylogue, index schema v23, 41,886 structured failures total, 5,000 origin-stratified failures inspected (3,746 claude-code-session, 1,247 codex-session, 7 claude-ai-export), 100 unpaired structured failures. Marker vocabulary was tightened to avoid broad issue/fix/block/gitignored false positives. Immediate next-turn totals: acknowledged=420, silent_proceed=1,205, ambiguous=3,375 (2,624 wordless tool continuations; 751 prose without marker). Lower-bound silent rate is 24.1%; among classified immediate next turns, silent rate is 74.2%. Next-3 sensitivity window, stopping before the next user message, finds 302 acknowledgments that appear only after the next turn; window3 silent lower bound is 37.0%. Calibration: 50 hand-labeled immediate-next-turn rows, acknowledged-marker precision=1.0, recall=0.8421052631578947, invalid rows=0. Artifact: .agent/demos/claim-vs-evidence/claim-vs-evidence.report.json.","notes":"2026-07-03 update: methodology package is now cold-read gated. .agent/demos/claim-vs-evidence contains aggregate live evidence, public-summary.json, PUBLIC_REPRODUCTION.md, COLD_READER_GATE.md, and COLD_READ_RESULT.md. Seeded reproduction is meaningful, not empty: 4 structured failures, 2 acknowledged follow-ups, 2 silent-proceed follow-ups, 0 unpaired. Cold-reader subagent PASS recovered claim/non-claim, sample frame, rates, calibration, caveats, and reproduction commands from the artifact directory only. Remaining campaign child: polylogue-sru.1 productizes action-unit outcome/followup_class capability.","status":"closed","priority":0,"issue_type":"epic","owner":"ezo.dev@gmail.com","created_at":"2026-07-03T04:31:26Z","created_by":"Sinity","updated_at":"2026-07-31T22:35:43Z","closed_at":"2026-07-03T09:28:09Z","close_reason":"Completed: all seven campaign children are closed. The claim-vs-evidence finding now has bounded sample-frame reporting, calibrated marker precision/recall, handler-class and next-3 sensitivity splits, meaningful seeded reproduction, cold-reader PASS, and productized action-unit followup_class/followup_message_ref query capability. Current artifact lives under .agent/demos/claim-vs-evidence and was regenerated against /home/sinity/.local/share/polylogue schema v23.","labels":["area:substrate","campaign"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"polylogue-z7sv3","title":"Make PR scope CI authority immutable at the base revision","description":"The first structured PR-scope carrier landed a green CI gate, but the validator still executes from the PR checkout and CircleCI can lack CIRCLE_PULL_REQUEST. Harden the process boundary so a pull request cannot weaken the validator it is being judged by.","design":"Modify devtools/pr_scope.py to resolve repository and PR metadata through GitHub REST, validate exact checkout/head identity, fetch the base revision validator, and run it in an isolated subprocess. Extend CircleCI quick-gate to call check-ci with CIRCLE_SHA1 and repo metadata. Make merge receipts bind scope_digest, beads_digest, and assigned IDs, and pass --match-head-commit to gh pr merge. Add unit coverage for no-PR-URL resolution, base-validator authority, schema rejection, receipt drift, and stale-head refusal. Remove the natural-language PR state guard and update lane/CI documentation.","acceptance_criteria":"1. CircleCI validates the exact checkout head and resolves the unique open PR when CIRCLE_PULL_REQUEST is absent. 2. When the base revision already contains pr_scope.py, CI executes that base validator rather than the PR-modified validator. 3. The carrier schema rejects unknown fields and merge receipts bind scope digest, Bead digest, and assigned Bead IDs. 4. The prose-parsing PR state guard is removed or replaced by structured validation. 5. Focused tests and devtools verify --quick pass.","status":"open","priority":1,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-06T06:44:24Z","created_by":"Sinity","updated_at":"2026-08-06T06:44:34Z","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"polylogue-taj0o","title":"Unify Claude Code eager and streaming parsers into one incremental multi-way merge","design":"Root architectural cause behind polylogue-4987i (session_events ordering\ninstability), which was fixed tactically in PR #3669 via a reconciliation\npass, not fixed structurally.\n\nCurrent design (path dependence, not principled):\n- Eager (parse_payload -\u003e dispatch.py grouping -\u003e _parse_code_records):\n materializes the ENTIRE raw JSONL payload into memory, groups ALL records\n by sessionId across the whole file (a complete partition, independent of\n file order), THEN feeds each session's full record list to\n _parse_code_records as one coherent single pass. Correct by construction\n because the parser never sees interleaving -- an earlier full-materialize\n step already removed it.\n- Streaming (parse_stream_payload -\u003e _claude_code_stream_sessions,\n dispatch.py:824): exists because raw JSONL ingest can be multi-GiB and\n can't be buffered wholesale. Instead of a true incremental multi-way\n merge, it takes a shortcut: detect CONTIGUOUS runs of the same sessionId\n and treat each run as an independent mini-file, reusing the exact same\n per-session \"I see the whole session at once\" parser\n (_parse_code_records via parse_code_stream) UNMODIFIED on each run. Chunks\n are then concatenated (merge_parsed_session_chunks) and reconciled after\n the fact (reconcile_code_session_chunks) to approximate what eager would\n have produced.\n\nWhy this is the wrong shape: reconcile_code_session_chunks has to\nre-implement, after the fact, every piece of session-wide accumulation\n_parse_code_records already does in its main loop (background-completion\ndedup, delegation-progress tick summation, coverage count summation,\nsession-wide event ordering) -- and every time a NEW session-wide summary\nevent type is added to the eager parser's main loop (which has happened\nseveral times, per its own comments: polylogue-pbuh AC5's coverage event,\ndelegation-progress events, session_kind), reconcile_code_session_chunks has\nto be remembered and updated to fold it too, or the same class of\neager-vs-streaming divergence bug recurs for the new event type. #3669 fixed\nthe THREE known cases; nothing prevents a fourth from being added without\nanyone updating reconcile. This is a structural bug-factory, not a one-off.\n\nProposed fix: replace both _claude_code_stream_sessions' contiguous-run\nchunking AND the eager grouping-then-parse call in dispatch.py's non-stream\npath with ONE incremental multi-way merge:\n- Walk the record stream exactly once, in file order (regardless of size).\n- Maintain a dict of open per-session accumulator state, keyed by session\n id -- the SAME state _parse_code_records currently builds up locally\n during its single-session main loop (messages, session_events-in-progress,\n delegation_progress dict, coverage counters, etc.), but keyed per session\n instead of assumed-singular.\n- Fold each record into its session's accumulator as it streams past\n (exactly the same per-record logic _parse_code_records already has, just\n addressed by session id instead of implicit \"the one session\").\n- Finalize (emit ParsedSession, apply order_session_events, run the\n post-loop coverage/background/delegation appends) a session's accumulator\n only when the stream ends (or, for true bounded-memory operation on\n extremely long-lived files, on an explicit flush signal -- out of scope\n for a first cut, current per-run memory is already \"proportional to\n unique record identifiers\" per _claude_code_stream_sessions' own\n docstring, i.e. already bounded well below full-file materialization).\n- Eager's dispatch.py grouping call and streaming's chunk-and-glue\n machinery both become this ONE function. reconcile_code_session_chunks,\n merge_parsed_session_chunks' claude-code-specific glue, and the\n eager/streaming duality in general are deleted, not deprecated\n (automagic-invariants doctrine: no break-glass tier once one path proven\n to correctly subsume the other).\n\nKnown hazards to preserve (read before touching):\n- Identity/carryover resolution (bd polylogue-jc4q, dispatch.py:848-863):\n contiguous-run-based primary/carryover detection for resume/fork/quirk\n boundaries. A multi-way merge needs the equivalent notion (which record\n run is THIS file's own primary content vs an ancestor's carryover\n prefix) re-derived under session-keyed accumulation, not run-keyed.\n Get this wrong and the fix reintroduces the exact bug this session's\n polylogue-slshy/polylogue-2hwl active-leaf-by-position lineage fixed.\n- Tool-result sidecar streaming join (polylogue-wjgf): currently teed\n through ToolResultIndexAccumulator per contiguous run, joined once a\n run's iterator is exhausted. Needs to become per-session-accumulator\n scoped instead of per-run scoped.\n- is_agent / agent-* fallback id special-casing (dispatch.py:878-882).\n- Sidecar join for the eager path (join_tool_result_sidecars, needs the\n full tool_use_id index) currently assumes full materialization; the\n merged design should reuse the SAME per-session-scoped join the\n streaming path already does, not the eager whole-file index -- one\n fewer thing that can diverge.\n\nScope note: this is a genuine parser-core rewrite of the hottest path in\nthe codebase (every Claude Code session, live and reindexed, goes through\nit). Do NOT attempt as a quick patch; needs its own dedicated session with\nfull regression coverage of tests/unit/sources/test_claude_code_normalization_laws.py,\ntest_claude_code_sidecar_evidence.py, test_parsers_claude_code_artifacts.py,\ntest_delegation_provider_fixtures.py, and a live-archive parity spot-check\n(parse every real multi-chunk/subagent-interleaved session in the archive\nboth ways, old vs new, before/after, diff zero).\n","notes":"ADDITIONAL FINDING (2026-08-03): this is a THREE-way duplication, not two. dispatch.py's eager grouping (_claude_code_grouped_record_specs, line 671) defines \"primary group\" as the group with the MOST records: `primary_group_id = max(groups, key=lambda group_id: len(groups[group_id]))`. Streaming's chunking (_claude_code_stream_sessions, line 948) defines primary as the group whose session_id equals the caller-supplied fallback_id: `is_primary_group = group_session_id == fallback_id`. These are NOT provably equivalent -- a file where the fallback_id-matching session has fewer records than another interleaved session in the same file would resolve differently under eager vs streaming.\n\nLive-archive check (read-only, source.db mode=ro): sampled 400 claude-code-session raw_sessions rows sized 200KB-5MB, zero contained \u003e1 distinct sessionId (i.e. zero genuinely session-interleaved files in that sample). Separately checked all 12 blob_hash values shared across \u003e1 distinct native_id in the whole archive -- these turned out to be a DIFFERENT, already-known phenomenon (polylogue-omsw's file-history-snapshot/artifact classification duplication, not sessionId-based session interleaving; the shared blobs contain zero \"sessionId\" fields at all). So: no live confirmed case of the eager/streaming primary-definition mismatch actually diverging on this archive today, but the code-level divergence is real and provable by inspection, not hypothetical -- it just hasn't been hit yet, or the two algorithms happen to agree in every case seen so far (files where the fallback_id-matching session also happens to have the most records, which is the common/expected shape).\n\nThis changes the design target for the unification: it's not just \"collapse eager-loop-state vs streaming-chunk-state into one accumulator\" (the code_parser.py duality already scoped), it ALSO needs ONE canonical \"which interleaved session is this file's own primary content\" algorithm shared by both paths, replacing both dispatch.py:671's max-by-count and dispatch.py:948's fallback_id-match (need to decide which definition, or a new one, is actually correct -- likely fallback_id-match, since that's grounded in the caller's own knowledge of which file this is, whereas max-by-count is a heuristic that could pick the WRONG group for a small main session with a huge subagent transcript in the same file).\n\nDecision: scoped as a dedicated lane dispatch (agent-executed, worktree-isolated, execution-grade design already documented above + this note) rather than attempted serially inline, per this repo's own orchestration doctrine and operator's earlier explicit correction this session (\"why are you not orchestrating anymore\"). Not a deferral -- dispatching now, in parallel with continued campaign work.\nStage 1 merged 2026-08-03 (PR #3680): _SessionAccumulator dataclass extraction from _parse_code_records, mechanical, zero behavior change (verified: rebased onto post-4987i master, full named regression suite 373/373 passed, mypy --strict clean). Stage 2 (the actual multi-way merge: key by session id, resolve the eager-vs-streaming primary-definition conflict, delete reconcile_code_session_chunks/merge_parsed_session_chunks's Claude-Code branch/_claude_code_stream_sessions/_claude_code_grouped_record_specs) remains open -- Stage 1 sets up the exact accumulator shape Stage 2 needs but does not itself unify eager/streaming. Bead stays open.","status":"closed","priority":1,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-03T18:41:03Z","created_by":"Sinity","updated_at":"2026-08-05T07:26:34Z","closed_at":"2026-08-05T07:26:34Z","close_reason":"Stage 2 already merged as 25434d0f0 (#3691): one incremental multi-way Claude Code accumulator with canonical fallback-id primary selection replaces eager/streaming duality. The named parity suites passed (480) and quick verification is recorded.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"polylogue-gbs02","title":"Acquire-only degraded mode: index-tier mismatch must not park raw acquisition","description":"Found closing mhx95 (2026-08-03). After deploy+source-migration, the daemon (current master) parks ALL 16 loops INCLUDING the live watcher on 'index.db:46!=57' — but acquisition writes only source.db, which is current (v24). Consequence: zero ingestion until 818fy runs, for no durable-tier reason; browser-capture/hook spools accumulate unprocessed and time-sensitive captures wait on a derived-tier rebuild. Fix shape: schema preflight distinguishes durable-tier mismatch (park everything — correct) from derived-only mismatch (run acquisition + spool drains + source-tier loops; park parse/materialize/index-writing loops). The parked-loop log line already enumerates loops, so the split is a classification over the existing registry. AC: with index.db deliberately at an old version and source.db current, the daemon acquires new raws (source.db row appears; spool drains) while materialization stays parked and health still reports the index mismatch. Falsification: revert the classification and the acquire test freezes again. Ref mhx95 evidence trail.","notes":"\n2026-08-03 design investigation (no code change -- this needs careful per-loop classification before touching live daemon startup sequencing, not a quick patch):\n\nCurrent structure (polylogue/daemon/cli.py ~2148-2191, health.py:341 _check_schema_version_fast):\n- _check_schema_version_fast() computes ONE aggregate severity across ALL tiers (source/index/embeddings/user/ops) with no per-tier durability distinction in its return value (HealthAlert has no detail/tier-breakdown field) -- it correctly reports CRITICAL whenever ANY tier's user_version mismatches, and the AC explicitly wants this UNCHANGED (\"health still reports the index mismatch\").\n- watcher_blocked = enable_watch and schema_alert.severity == CRITICAL currently gates BOTH the live watcher AND all 14 named loops in _SCHEMA_BLOCKED_MAINTENANCE_LOOP_NAMES via one shared `if not watcher_blocked:` block (cli.py ~2253+) -- they start together or not at all.\n\nThe fix needs TWO independent gates, not a narrowed version of the existing one:\n1. A new, SEPARATE durable-tier-only check (source.db + user.db, durability in {\"irreplaceable\",\"human\"} per ARCHIVE_TIER_SPECS) -- call it durable_mismatch. Only THIS should gate the live watcher + acquisition/spool-drain loops (the AC's \"source-tier loops\"). Add as a new function alongside _check_schema_version_fast, not a modification to it (that function's HealthAlert-typed return is consumed elsewhere for periodic health reporting and must keep reporting the FULL aggregate severity, per the AC).\n2. The EXISTING aggregate check (any tier, i.e. current behavior) must keep gating every loop that writes a derived tier (index.db/embeddings.db) -- raw materialization convergence, session insight convergence, convergence debt retry, embedding backlog catch-up, embedding orphan reconcile, fts merge, fts identity drift recompute, fts orphan audit, db optimize (likely index-tier VACUUM/ANALYZE) -- these must NOT start on a stale index.db even once gate 1 is relaxed.\n\nPer-loop classification still needed (NOT done this session -- each of the 14 names in _SCHEMA_BLOCKED_MAINTENANCE_LOOP_NAMES needs its actual write-tier confirmed by reading its implementation, not guessed from its name):\n- Likely index/embeddings-tier (must stay gated on ANY mismatch): raw materialization convergence, session insight convergence, convergence debt retry, embedding backlog catch-up, embedding orphan reconcile, fts merge, fts identity drift recompute, fts orphan audit, db optimize, judgment automation sweep (uses embeddings for judgment scoring, verify).\n- Likely source-tier-only or tier-agnostic (candidates to move to gate 1, i.e. safe to run on derived-only mismatch): wal checkpoint (verify which db(s) it checkpoints), heartbeat, status snapshot refresh (verify what it snapshots), blob gc check (blob store is source-tier), secret scan sweep (likely scans raw content = source-tier).\n- drive source catch-up (_SCHEMA_BLOCKED_OPTIONAL_DRIVE_CATCHUP_LOOP_NAME): acquisition-adjacent, likely gate-1 candidate.\n\nRisk if this is done wrong: a loop incorrectly reclassified as \"safe\" that actually writes index.db against a stale schema could silently corrupt the live production index during exactly the highest-stakes window (mid-reindex-campaign). This needs the per-loop write-tier confirmed by reading each loop's actual body, then a real test proving the split (per this bead's own AC: index.db old + source.db current -\u003e watcher runs + source.db row appears, materialization loops provably don't start), not inferred from loop names. Left for a dedicated implementation pass with that verification, not attempted blind in this session.","status":"closed","priority":1,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-03T16:09:27Z","created_by":"Sinity","updated_at":"2026-08-03T17:00:10Z","closed_at":"2026-08-03T17:00:10Z","close_reason":"Implemented acquire-only degraded mode: DegradedReason.derived_only flag + is_fully_degraded() (core/degraded.py), durable_tier_schema_mismatch() narrow check (daemon/health.py), two-gate split in daemon/cli.py (watcher_blocked for maintenance loops, watcher_creation_blocked for the watcher itself), acquire-then-skip-parse in both batch.py and append_ingest.py (the primary tailed-file path, which had no degraded check at all before). Regression test proves the exact AC (raw acquired, parsed_at_ms NULL, no parse_error). Commit cb60c02a3, devtools test 2211 passed (2 pre-existing load-flaky failures unrelated).","dependencies":[{"issue_id":"polylogue-gbs02","depends_on_id":"polylogue-9qnzy","type":"relates-to","created_at":"2026-08-03T18:23:53Z","created_by":"Sinity","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"polylogue-0v4tn","title":"blob_refs GC oracle broken: 73,427 raw_payload + 1,336 attachment refs orphaned (hook-deinflation residue)","description":"Baseline census 2026-08-03 (invariant I3): 73,427 of 116,149 raw_payload blob_refs and ALL 1,336 attachment blob_refs have ref_ids that no longer resolve in their referent tables — overwhelmingly the hook-deinflation residue (64,896 raw_sessions rows deleted 2026-07-22 without pruning their blob_refs; same class as closed i3zo for raw_authority_plans). Consequences: (a) blob GC's snapshot-reference safety check treats ~73K blobs as referenced forever — GC can never collect them; (b) any 'blobstore pristine / no weirdness' claim (r9xsj) is false while the reference substrate lies. Blob FILES are fine (300/300 + 100/100 presence samples pass); this is bookkeeping-tier. Fix shape: set-based orphan identification (LEFT JOIN refs to referents) + prune in one guarded pass with a receipt, mirroring i3zo/PR #3530's pattern; then re-run I3 to 0. Attachment refs need their own referent-table check first — determine what ref_id should point at (attachment_refs moved tiers historically) before deleting anything. Baseline artifact: .agent/scratch/reindex-baseline-2026-08-03.md.","status":"in_progress","priority":1,"issue_type":"bug","assignee":"Sinity","owner":"ezo.dev@gmail.com","created_at":"2026-08-03T15:39:48Z","created_by":"Sinity","updated_at":"2026-08-05T05:25:03Z","started_at":"2026-08-05T05:25:03Z","lease_expires_at":"2026-08-05T05:30:03Z","heartbeat_at":"2026-08-05T05:25:03Z","dependency_count":0,"dependent_count":1,"comment_count":0} @@ -652,7 +653,7 @@ {"_type":"issue","id":"polylogue-22ldr","title":"Map Gemini/AI-Studio codeExecutionResult.outcome to tool_result_is_error","description":"P-class (reindex-gate-hunt task #1, adjudicated 2026-08-03). polylogue/sources/providers/gemini_message.py:113-124 _tool_result_content_block constructs every TOOL_RESULT block from Gemini/AI-Studio codeExecutionResult (call sites :303, :312) and never sets ContentBlock.is_error/.exit_code — blocks.tool_result_is_error lands NULL unconditionally for this provider family (write.py:97-98 reads it straight from the parsed block).\n\nProof the signal exists and is dropped (not absent upstream): the provider schema fingerprint (schemas/providers/gemini/versions/v2/elements/session_document.schema.json.gz) records $.chunkedPrompt.chunks[*].codeExecutionResult.outcome field-length stats avg=11.3 min=10 max=14 — exactly bracketing OUTCOME_OK (10) and OUTCOME_FAILED (14), so the live corpus contains BOTH outcomes and real tool failures are being read past.\n\nContrast: the gemini-cli sibling path (local_agent.py:584-620, different dispatch route) DOES set is_error/exit_code from its envelope — this parser simply lags its sibling. Related: cuxz.4 AC#3 asks for exactly this kind of gap to be enumerated; this bead is one named instance. is_error is populated at parse/write time only (no independent backfill), so unfixed it survives any rebuild as NULL — board the 818fy batch.\n","acceptance_criteria":"gemini_message.py _tool_result_content_block sets is_error from outcome: OUTCOME_OK -\u003e False; OUTCOME_FAILED / OUTCOME_DEADLINE_EXCEEDED -\u003e True; OUTCOME_UNSPECIFIED/absent -\u003e None. exit_code stays None (no equivalent in payload). Fixture test covers both OK and FAILED shapes. Boards the 818fy reparse batch so existing NULL rows repopulate.","status":"open","priority":2,"issue_type":"bug","owner":"ezo.dev@gmail.com","created_at":"2026-08-03T12:19:08Z","created_by":"Sinity","updated_at":"2026-08-03T12:19:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"polylogue-iltbx","title":"Widen block content-hash payload with tool_result outcome fields (is_error/exit_code/outcome_unknown_reason)","description":"P-class, efficiency-critical timing (reindex-gate-hunt tasks #5+#10, adjudicated 2026-08-03). Persisted-but-unhashed parsed fields form acquire-time skip-drift channels: a re-acquired raw whose only delta is an unhashed field produces an identical content hash, so hash-skip idempotency leaves the index row stale until a full rebuild.\n\nEnumeration verdict (task #10): the ONLY high-realism channel is the tool_result outcome trio — code_parser.py _task_output_outcome (lines ~900-925) documents Claude Code polling a background task twice with the real verdict only on the second poll; that corrected outcome is currently hash-invisible. Write path already treats is_error/exit_code as content-bearing (block-level content_hash in write.py). Explicit NO-ACTION set: signature (deliberately excluded — providers re-sign every replay; including it would break fork-prefix/citation matching, vf9x); position/branch_index/is_active_path (documented array-order-exclusion design); message-level usage/model/stop_reason (low-realism under append-only acquisition; unresolved items stay unresolved, not folded in).\n\nTIMING (the actual reason this bead exists now): xselt lowering_fingerprint hashes pipeline/ids.py identity/hash function source — one global value; ANY later widening forces a full-archive differential reparse by construction. Landing inside the 818fy batch makes that reparse free (already happening); landing after costs a dedicated full pass.\n","acceptance_criteria":"pipeline/ids.py _content_block_payload includes tool_result_is_error, tool_result_exit_code, tool_result_outcome_unknown_reason (None-sentinel normalized). signature remains EXCLUDED (deliberate, vf9x re-sign-on-replay). Test: two parses differing only in is_error produce different session content hashes; re-acquisition with a corrected outcome triggers re-write. Landed inside the 818fy semantic-reparse batch (before or with the rebuild), never after xselt stamps without a fingerprint plan.","status":"open","priority":2,"issue_type":"bug","owner":"ezo.dev@gmail.com","created_at":"2026-08-03T12:19:08Z","created_by":"Sinity","updated_at":"2026-08-03T12:19:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"polylogue-m73wk","title":"Verify v50 thinking/reasoning recovery post-rebuild (zn1k-shaped successor for the unmet 8b10 closure gate)","description":"V-class (reindex-gate-hunt task #6, adjudicated 2026-08-03): gates 818fy ACCEPTANCE completeness, not its start. Beads r39b/mctu/8b10 (v50: recover Claude Code thinking blocks dropped by an if-text guard in base_support.py content_blocks_from_segments; materialize standalone Codex reasoning records) were closed 2026-08-02T22:09Z although 8b10's own notes state its closure gate — post-rebuild sum(thinking_count) non-zero — was never met (the rebuild has not run). Grep-verified: zero coverage of thinking_count/reasoning-block checks in 818fy, f1vg, r9xsj, or t0m73. The structurally identical v48 case (zn1k) is correctly open with depends_on:818fy — this bead restores the same pattern for v50. 818fy AC #5 amendment recorded in 818fy notes (same beading batch).\n\nThird instance of the recurring closure pattern (forward-fix closes, verification/repair AC deferred without successor) — see the closure-discipline process bead from this batch.\n","acceptance_criteria":"After the 818fy rebuild promotes: per-origin sum(thinking_count) \u003e 0 for claude-code-session and codex-session populations known to carry thinking/reasoning in raw; the specific pre-v50 dropped shapes (empty-text+signature-only THINKING segments; standalone Codex reasoning records) demonstrably materialize as blocks. Result recorded on this bead; if zero, the v50 parser fix is re-opened as failed.","status":"open","priority":2,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-03T12:19:08Z","created_by":"Sinity","updated_at":"2026-08-03T12:19:08Z","dependencies":[{"issue_id":"polylogue-m73wk","depends_on_id":"polylogue-818fy","type":"blocks","created_at":"2026-08-03T14:19:07Z","created_by":"Sinity","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"polylogue-1cbeh","title":"Wire merge-gate verdict as a GitHub status check to enable safe auto-merge","description":"Coordinator manually cycled checkout-\u003etest-\u003emerge-gate-\u003emerge 18 times this session (2026-08-03), one PR at a time -- the single biggest process bottleneck found this session. Every piece needed for safe auto-merge already exists except one: merge-gate check verdict is a local CLI result, not a GitHub-visible check/status, so `gh pr merge --auto` and branch protection have nothing to wait on.\n\nFix: after a lane records its own merge-gate receipt (per the standing lane.md contract added today) and merge-gate check passes (including its CodeRabbit grace-period poll and unacked-actionable-comment block), post a GitHub commit status (`gh api repos/OWNER/REPO/statuses/SHA -f state=success -f context=merge-gate ...`) reflecting that verdict. Add merge-gate to required status checks in branch protection. Lanes then call `gh pr merge --auto --squash --delete-branch` as their actual final step instead of leaving the PR open for a human/coordinator to manually cycle through.\n\nSafety already covered by existing merge-gate semantics: real CodeRabbit findings keep the status pending/failed (forcing the same triage done by hand today for PRs #3613/#3631); real git conflicts just fail to merge, GitHub handles that natively; per-PR CI already required (lint+mypy). No new risk surface, just making an existing local verdict visible to GitHubs own gating mechanism.","status":"open","priority":2,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-03T10:53:10Z","created_by":"Sinity","updated_at":"2026-08-03T10:53:10Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"polylogue-1cbeh","title":"Wire merge-gate verdict as a GitHub status check to enable safe auto-merge","description":"Coordinator manually cycled checkout-\u003etest-\u003emerge-gate-\u003emerge 18 times this session (2026-08-03), one PR at a time -- the single biggest process bottleneck found this session. Every piece needed for safe auto-merge already exists except one: merge-gate check verdict is a local CLI result, not a GitHub-visible check/status, so `gh pr merge --auto` and branch protection have nothing to wait on.\n\nFix: after a lane records its own merge-gate receipt (per the standing lane.md contract added today) and merge-gate check passes (including its CodeRabbit grace-period poll and unacked-actionable-comment block), post a GitHub commit status (`gh api repos/OWNER/REPO/statuses/SHA -f state=success -f context=merge-gate ...`) reflecting that verdict. Add merge-gate to required status checks in branch protection. Lanes then call `gh pr merge --auto --squash --delete-branch` as their actual final step instead of leaving the PR open for a human/coordinator to manually cycle through.\n\nSafety already covered by existing merge-gate semantics: real CodeRabbit findings keep the status pending/failed (forcing the same triage done by hand today for PRs #3613/#3631); real git conflicts just fail to merge, GitHub handles that natively; per-PR CI already required (lint+mypy). No new risk surface, just making an existing local verdict visible to GitHubs own gating mechanism.","status":"open","priority":2,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-03T10:53:10Z","created_by":"Sinity","updated_at":"2026-08-03T10:53:10Z","dependencies":[{"issue_id":"polylogue-1cbeh","depends_on_id":"polylogue-z7sv3","type":"blocks","created_at":"2026-08-06T08:44:53Z","created_by":"Sinity","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"polylogue-e2uns","title":"Audit devtools tooling self-sprawl: dual invocation conventions, closed-loop checks, category density","design":"Findings from a 2026-08-03 cruft audit (subagent \"audit-devtools-sprawl\"), read-only, not yet acted on:\n\n1. Dual invocation conventions: 7 real, tested/used devtools modules bypass the `devtools \u003ccommand\u003e` CommandSpec dispatch entirely, invoked as raw `python -m devtools.X` / `python devtools/X.py` scripts instead: benchmark_compare_nightly.py (invoked from .github/workflows/nightly-scale.yml:73), verify_mutation_freshness.py (invoked from .github/workflows/mutation-testing.yml:52), pre_push_gate.py (invoked from .githooks/pre-push), raw_append_chain_backfill_apply.py (documented only in a bead note, polylogue-818fy), reconcile_tracker_authority.py (documented in docs/tracker-authority.md:59-60), resume_ranking_eval.py (has tests, no CommandSpec), render_semantic_card_registry.py / render_semantic_card_fixtures.py. None of these show up in `devtools status`/`--help`. Consider either wiring them into command_catalog.py for discoverability, or documenting the split convention explicitly (e.g. \"CI-only scripts\" vs \"operator commands\").\n\n2. render_semantic_card_registry.py / render_semantic_card_fixtures.py generate docs/generated/semantic-card-tool-map.md but are NOT wired into render_all.py's surface list or command_catalog — nothing re-runs or verifies them at `render all --check` time, so this doc can go silently stale, the exact failure mode the render-all freshness gate exists to prevent everywhere else.\n\n3. Category density: of 138 total CommandSpecs, `workspace` alone is 52 (38%). Not inherently a problem, but worth a pass to check for near-duplicate workspace commands.\n\n4. Closed-loop checks: 15 of ~27 commands wired into `verify --quick`/`--all` (verify manifests, verify ci-workflows, verify test-infra-currency, verify pytest-timeout-overrides, verify degrade-loudly, lab policy demo-tour-freshness/raw-payload-hash-purity/position-derived-identity/raw-authority-frontier-executability/backlog-hygiene/timestamp-doctrine/insight-honesty/docs-drift/campaign-archive-boundaries, bench slo) have zero human-facing documentation anywhere outside auto-generated reference docs — pure closed loop, only the gate itself would notice if one were subtly wrong or miscalibrated. (Note: most of these were separately audited 2026-08-03 and found to be legitimate behavioral checks, not fossilized-diff bureaucracy — this finding is about documentation/discoverability, not correctness.)\n\n5. `devtools workspace lane-init` shows only 2 commit-message mentions in git history despite CLAUDE.md billing it as \"load-bearing, use every time\" for lane worktree provisioning — worth checking whether that mandate is actually being followed in practice, or whether the doc oversells actual usage.\n\n6. A 6-way \"is docs in sync with code\" cluster exists (verify doc-commands, verify docs-coverage, lab policy docs-drift, verify manifests, verify closure-matrix, render docs-surface) — each has genuinely distinct scope/mechanism (not byte-identical duplicates) but real overlap at the category-intent level. Worth a pass to check whether any two could merge without losing coverage.\n\nNone of these are urgent; this bead exists to make the findings durable and trackable rather than let them evaporate at end of session.","notes":"Dissection 2026-08-03 category measurement (feed for this audit): devtools = 82,285 lines / 202 files; verify* 14.6K/34 files · probe/proof 12.5K/20 · render* 5.9K/21 · *report* 5.8K/14 · beads tools 3.8K/5 · workspace/lane/merge 3.5K/11 · command_catalog 2.3K. tests/unit/devtools = 34,733 (second-order verification). Verdicts proposed (operator ratifies): raw-authority proofs (scale/restart/daemon-health, 2.7K) die with the acquire-time-authority root fix; process-analytics trio (trajectory_report self-describes as 'the missing third view' beside beads_state_report + backlog-calibration) -\u003e keep backlog-calibration only (~3-4K out); claim_vs_evidence 1.7K + affordance_usage 1.4K are product questions — promote to insights/ or close as one-shots; render family audited by actual readership. Report: /realm/data/derived/reports/polylogue-structural-dissection-2026-08-03.html.\nPER-FILE DISPOSITION LIST (iteration 6, 2026-08-03 — execution is now a checklist; delete = rm file + its tests + command-catalog entry + render refs): DELETE-WITH-R1 (premise dies with acquire-time authority / drain): raw_authority_scale_proof.py 1170, raw_authority_restart_proof.py 1005, raw_authority_daemon_health_proof.py 519, verify_raw_authority_frontier_executability.py 261. DELETE-SPENT (version-pinned one-time actuators for past generations; current index far beyond their range): index_fast_forward.py 1085 ('v32-\u003ev35' in its own docstring), archive_schema_fast_forward.py 985 ('accepts only the observed v35 file set'); confirm no lifecycle.py import ties first (the runtime fast-forward in storage/sqlite/lifecycle.py is SEPARATE and stays). DELETE-SPENT: codex_exec_child_census.py 308 (one-shot census comparing pre/post child-projection parsers; projection shipped). DELETE-NOW: trajectory_report.py 1173 (third dev-process view); beads_state_report.py 2542 AFTER folding its graph-health checks into workspace backlog-calibration (d63uz overlaps — coordinate). PROMOTE-OR-DELETE after their one-shot runs: claim_vs_evidence.py 1712 (67ac owns the experiment), affordance_usage.py 1416 (product analytics question — insights/ or gone). PENDING DISSECT-10 audit: continuity_replay.py 1935 + mandate_continuity_replay + render_product_workflows (the product/workflows closed-loop family — disposition follows the executable-route audit). KEEP: dev_loop.py (live dev preflight), deployment_smoke.py (deployed-surface probe), daemon_workload_probe.py (operator diagnostic; slim candidate later), verify.py/verify_runs.py/command_catalog.py (harness core), workspace/lane/merge family (load-bearing fanout tooling). Everything not named: unexamined, do not sweep blind.","status":"open","priority":2,"issue_type":"chore","owner":"ezo.dev@gmail.com","created_at":"2026-08-03T10:50:18Z","created_by":"Sinity","updated_at":"2026-08-03T14:25:12Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"polylogue-m6tjl","title":"Origins x capabilities matrix: parser claims vs live census, every cell verdicted","description":"9 origins x (parent links, titles, timestamp confidence, attachments, tool pairing, thread structure, events, costs): parser-code claims cross-checked against mode=ro census; every empty cell is declared-impossible (provider does not ship it) or a finding. ksgg found one hole; the matrix denominator is exact.","acceptance_criteria":"1. Rendered matrix committed (doc or generated). 2. Every empty cell annotated. 3. New findings beaded with discovered-from this bead.","notes":"Dissection 2026-08-03 L12 retriage: run ONCE as an audit; the standing-matrix half belongs to OriginSpec (2qx) which declares capabilities as data — a hand-maintained matrix beside it would be a second register of the same facts.","status":"open","priority":2,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-03T07:40:55Z","created_by":"Sinity","updated_at":"2026-08-03T13:16:17Z","labels":["area:sources"],"dependencies":[{"issue_id":"polylogue-m6tjl","depends_on_id":"polylogue-ksgg","type":"relates-to","created_at":"2026-08-03T09:40:54Z","created_by":"Sinity","metadata":"{}"},{"issue_id":"polylogue-m6tjl","depends_on_id":"polylogue-wwph1","type":"relates-to","created_at":"2026-08-03T09:40:54Z","created_by":"Sinity","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"polylogue-ky67h","title":"lab check: evidence-of-execution — automation with zero lifetime runs","description":"Inventory every daemon loop/convergence stage/maintenance path from code; join against live ops.db daemon events. Believed-running-never-fired (zoek0 class; 5xxmc's frozen 12 loops) becomes a standing check — the automagic-invariants doctrine given teeth.","acceptance_criteria":"1. Stage inventory is code-derived, not hand-listed. 2. Zero-execution automations reported with last-run timestamps for the rest. 3. Dedupe vs t0m73 resolved.","status":"open","priority":2,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-03T07:40:51Z","created_by":"Sinity","updated_at":"2026-08-03T07:40:51Z","labels":["area:daemon"],"dependencies":[{"issue_id":"polylogue-ky67h","depends_on_id":"polylogue-t0m73","type":"relates-to","created_at":"2026-08-03T09:40:51Z","created_by":"Sinity","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} From a4c0d7ffeae4cb1357fd941b0fea3462425b82c9 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 6 Aug 2026 09:03:11 +0200 Subject: [PATCH 04/10] fix(devtools): harden PR scope authority Problem: review found that the structured PR scope check could accept an ambiguous CI state, consult the wrong validator revision, and record receipts before verifying the local tree. Agent publication instructions also assigned carrier ownership inconsistently. What changed: distinguish commits with no open PRs from API failures, prefer the checked-out base validator object, defer scope validation until checkout guards pass, and cover the new failure paths with focused tests. Clarify that the coordinator owns carrier rendering and PR publication. Compatibility/migration: non-master CircleCI builds with no open PR now skip the scope check; network and metadata failures remain blocking. Ref polylogue-z7sv3 Co-Authored-By: Claude --- .codex/agents/narrow-worker.toml | 12 +++--- .codex/agents/worker.toml | 15 ++++--- devtools/command_catalog.py | 3 +- devtools/merge_gate.py | 22 +++++----- devtools/pr_scope.py | 16 ++++++++ tests/unit/devtools/test_merge_gate.py | 1 + tests/unit/devtools/test_pr_scope.py | 57 ++++++++++++++++++++++++++ 7 files changed, 99 insertions(+), 27 deletions(-) diff --git a/.codex/agents/narrow-worker.toml b/.codex/agents/narrow-worker.toml index da1bda7b90..39cc3a0434 100644 --- a/.codex/agents/narrow-worker.toml +++ b/.codex/agents/narrow-worker.toml @@ -27,10 +27,10 @@ write to Beads, do not merge or push, do not touch /realm/db/polylogue. Commit coherent checkpoints. Return exact changed files, commands run and their output, an acceptance-criteria match table, and residual uncertainty. -For a lane that publishes a PR, emit the versioned PR-scope carrier before the -non-draft PR is opened: render it from assigned Bead IDs, typed whole-Bead -dispositions, evidence refs, and open successors for residual scope using -`devtools workspace pr-scope render`. Embed the result in the PR body and run -`devtools workspace pr-scope check --pr `. Never derive a disposition by -parsing acceptance prose or fabricate a successor Bead ID. +Provide the coordinator with a JSON scope input containing assigned Bead IDs, +typed whole-Bead dispositions, evidence refs, and open successors for residual +scope. The coordinator owns rendering the versioned carrier, embedding it in +the PR body, validating the published non-draft PR, and opening or updating the +PR. Never derive a disposition by parsing acceptance prose or fabricate a +successor Bead ID. """ diff --git a/.codex/agents/worker.toml b/.codex/agents/worker.toml index 1414d3bec7..d84b7442a9 100644 --- a/.codex/agents/worker.toml +++ b/.codex/agents/worker.toml @@ -35,12 +35,11 @@ changed files; the exact commands you ran and their output; an acceptance-criteria match table against the bead(s) you were assigned; residual uncertainty; and the commit hash(es). -Before publishing a non-draft PR, create a JSON scope input that names every -assigned Bead, one whole-Bead disposition per ID, typed evidence refs, and an -existing open successor for every partial/deferred/superseded outcome. Render -the embedded carrier with `devtools workspace pr-scope render`, put that exact -comment in the PR body beside the human disposition matrix, and validate the -published PR with `devtools workspace pr-scope check --pr `. Do not ask a -machine to infer Bead acceptance from prose and do not invent missing Bead IDs; -report missing IDs to the coordinator. +Provide the coordinator with a JSON scope input that names every assigned Bead, +one whole-Bead disposition per ID, typed evidence refs, and an existing open +successor for every partial/deferred/superseded outcome. The coordinator owns +rendering the embedded carrier, putting it in the PR body, validating the +published non-draft PR, and opening or updating the PR. Do not ask a machine to +infer Bead acceptance from prose and do not invent missing Bead IDs; report +missing IDs to the coordinator. """ diff --git a/devtools/command_catalog.py b/devtools/command_catalog.py index 7ba46beea1..b4f162939b 100644 --- a/devtools/command_catalog.py +++ b/devtools/command_catalog.py @@ -830,8 +830,7 @@ class CatalogBypassSite: "Replace a bare `gh pr merge --squash` with this at the actual merge boundary " "(polylogue-ct3r2 / polylogue-t6iga: duplicate filings of the same finding -- " "`merge-gate record/check` and the one-full-verify-per-train rule both existed but " - "fired only if a coordinator remembered to invoke them). `merge ` auto-records a " - "validates the non-draft PR's structured scope carrier and auto-records a merge-gate receipt if none is fresh for the current head sha (running `--command`, " + "fired only if a coordinator remembered to invoke them). `merge ` validates the non-draft PR's structured scope carrier and auto-records a merge-gate receipt if none is fresh for the current head sha (running `--command`, " 'default "devtools verify"), runs `merge-gate check` and refuses to merge on any ' "BLOCK, strips a doubled `(#N) (#N)` squash-subject suffix (the 2026-07-12/13 " "incident), then runs the actual `gh pr merge --squash`. `--dry-run` runs every check " diff --git a/devtools/merge_gate.py b/devtools/merge_gate.py index 02b96990ed..b728d54c3e 100644 --- a/devtools/merge_gate.py +++ b/devtools/merge_gate.py @@ -222,17 +222,6 @@ def cmd_record(pr: int, command: str) -> int: info = _gh_json(["pr", "view", str(pr), "--json", "headRefOid,headRefName,body,isDraft"]) head_sha = info["headRefOid"] - scope = pr_scope.validate_pr_body( - info.get("body") or "", - head_sha=head_sha, - is_draft=bool(info.get("isDraft")), - ) - if not scope.ok: - print(f"REFUSING to record: PR #{pr} has an invalid structured pr-scope carrier:", file=sys.stderr) - for reason in scope.reasons: - print(f" - {reason}", file=sys.stderr) - return 2 - local_head = _git_head_sha() if local_head != head_sha: print( @@ -251,6 +240,17 @@ def cmd_record(pr: int, command: str) -> int: ) return 2 + scope = pr_scope.validate_pr_body( + info.get("body") or "", + head_sha=head_sha, + is_draft=bool(info.get("isDraft")), + ) + if not scope.ok: + print(f"REFUSING to record: PR #{pr} has an invalid structured pr-scope carrier:", file=sys.stderr) + for reason in scope.reasons: + print(f" - {reason}", file=sys.stderr) + return 2 + argv = shlex.split(command) if not argv: print("REFUSING to record: --command is empty after shell splitting.", file=sys.stderr) diff --git a/devtools/pr_scope.py b/devtools/pr_scope.py index 955ec8faca..92beecfab4 100644 --- a/devtools/pr_scope.py +++ b/devtools/pr_scope.py @@ -75,6 +75,10 @@ class PullRequestMetadata: is_draft: bool +class NoOpenPullRequestError(ValueError): + """CI checked a commit that has no open pull request to validate.""" + + def _canonical_json(value: object) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -431,12 +435,21 @@ def fetch_pr_for_head(*, repository: str, head_sha: str) -> tuple[int, PullReque metadata = _pr_metadata_from_payload(item) if metadata.head_sha == head_sha: candidates.append((item["number"], metadata)) + if not candidates: + raise NoOpenPullRequestError(f"no open PR found for head {head_sha[:8]}") if len(candidates) != 1: raise ValueError(f"expected one open PR for head {head_sha[:8]}, found {len(candidates)}") return candidates[0] def fetch_base_validator_source(*, repository: str, base_sha: str) -> bytes | None: + local = subprocess.run( + ["git", "show", f"{base_sha}:devtools/pr_scope.py"], + capture_output=True, + check=False, + ) + if local.returncode == 0 and local.stdout: + return local.stdout path = f"repos/{repository}/contents/devtools/pr_scope.py?ref={parse.quote(base_sha, safe='')}" return _github_request_bytes(path, accept="application/vnd.github.raw+json", missing_ok=True) @@ -584,6 +597,9 @@ def main(argv: list[str] | None = None) -> int: checkout_head_sha=checkout_head_sha, expected_head_sha=args.expected_head_sha, ) + except NoOpenPullRequestError as exc: + print(f"pr-scope CI skip: {exc}", file=sys.stderr) + return 0 except (OSError, ValueError, json.JSONDecodeError, RuntimeError, subprocess.SubprocessError) as exc: print(f"REFUSING CI pr-scope check: {exc}", file=sys.stderr) return 2 diff --git a/tests/unit/devtools/test_merge_gate.py b/tests/unit/devtools/test_merge_gate.py index 93ea9906d8..6d26285f94 100644 --- a/tests/unit/devtools/test_merge_gate.py +++ b/tests/unit/devtools/test_merge_gate.py @@ -201,6 +201,7 @@ def test_check_ok_when_receipt_fresh_and_matches_head_with_no_late_comments( @pytest.mark.parametrize( ("receipt_field", "mutated_value", "reason"), [ + ("pr_scope_digest", "changed-body-digest", "pr_scope_digest"), ("pr_scope_beads_digest", "stale", "pr_scope_beads_digest"), ("pr_scope_assigned_beads", ["polylogue-other"], "pr_scope_assigned_beads"), ], diff --git a/tests/unit/devtools/test_pr_scope.py b/tests/unit/devtools/test_pr_scope.py index 4b62b0723c..8d4cb7b4d3 100644 --- a/tests/unit/devtools/test_pr_scope.py +++ b/tests/unit/devtools/test_pr_scope.py @@ -171,6 +171,63 @@ def _urlopen(api_request: request.Request, *, timeout: int) -> _FakeHttpResponse assert requests[0].full_url == f"https://api.github.com/repos/Sinity/polylogue/commits/{HEAD_SHA}/pulls" +def test_fetch_pr_for_head_reports_when_no_open_pr_matches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(request, "urlopen", lambda *_args, **_kwargs: _FakeHttpResponse(b"[]")) + + with pytest.raises(pr_scope.NoOpenPullRequestError, match="no open PR"): + pr_scope.fetch_pr_for_head(repository="Sinity/polylogue", head_sha=HEAD_SHA) + + +def test_ci_skips_when_commit_has_no_open_pr( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(pr_scope, "resolve_repository", lambda _repo: "Sinity/polylogue") + monkeypatch.setattr(pr_scope, "_git_head_sha", lambda: HEAD_SHA) + + def _no_pr(**_kwargs: object) -> tuple[int, pr_scope.PullRequestMetadata]: + raise pr_scope.NoOpenPullRequestError("no open PR found for head aaaaaaaa") + + monkeypatch.setattr(pr_scope, "fetch_pr_for_head", _no_pr) + + assert pr_scope.main(["check-ci", "--repo", "Sinity/polylogue", "--expected-head-sha", HEAD_SHA]) == 0 + assert "pr-scope CI skip" in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("checkout_head_sha", "expected_head_sha"), + [("c" * 40, HEAD_SHA), (HEAD_SHA, "d" * 40)], +) +def test_ci_check_refuses_head_mismatch_before_fetching_base( + checkout_head_sha: str, + expected_head_sha: str, + monkeypatch: pytest.MonkeyPatch, + beads_path: Path, +) -> None: + metadata = pr_scope.PullRequestMetadata( + body=_body(_input(), beads_path), + head_sha=HEAD_SHA, + base_sha="b" * 40, + is_draft=False, + ) + fetch_base = MagicMock() + monkeypatch.setattr(pr_scope, "fetch_base_validator_source", fetch_base) + + assert ( + pr_scope.check_ci_metadata( + metadata, + repository="Sinity/polylogue", + beads_path=beads_path, + checkout_head_sha=checkout_head_sha, + expected_head_sha=expected_head_sha, + ) + == 2 + ) + fetch_base.assert_not_called() + + def test_ci_check_executes_base_revision_validator( monkeypatch: pytest.MonkeyPatch, beads_path: Path, From f1085c92089bb21bde3a04a80827ceaeccc07999 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 6 Aug 2026 09:09:56 +0200 Subject: [PATCH 05/10] fix(devtools): validate PR residual graph links Problem: residual Bead successors were accepted when they merely existed, and draft pull requests could pass the base-validator subprocess because its body-check path defaulted to non-draft. What changed: require a durable Beads relationship for every residual successor and refuse draft PRs before invoking the authoritative base checker. Added focused acceptance and rejection tests. Ref polylogue-z7sv3 Co-Authored-By: Claude --- devtools/pr_scope.py | 21 +++++++++++ tests/unit/devtools/test_pr_scope.py | 53 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/devtools/pr_scope.py b/devtools/pr_scope.py index 92beecfab4..8dab3e4907 100644 --- a/devtools/pr_scope.py +++ b/devtools/pr_scope.py @@ -56,6 +56,7 @@ class EvidenceKind(StrEnum): {ScopeDisposition.PARTIAL.value, ScopeDisposition.DEFERRED.value, ScopeDisposition.SUPERSEDED.value} ) _EVIDENCE_KINDS = frozenset(item.value for item in EvidenceKind) +_SUCCESSOR_LINK_TYPES = frozenset({"blocks", "discovered-from", "relates-to", "supersedes"}) @dataclass(frozen=True, slots=True) @@ -116,6 +117,21 @@ def canonical_beads_digest(records: dict[str, dict[str, Any]], bead_ids: list[st return _digest({"version": _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: + """Require a durable Beads relationship between a source and its successor.""" + for record_id, target_id in ((source_id, successor_id), (successor_id, source_id)): + record = records.get(record_id) + dependencies = record.get("dependencies") if record is not None else None + if not isinstance(dependencies, list): + continue + for dependency in dependencies: + if not isinstance(dependency, dict): + continue + if dependency.get("depends_on_id") == target_id and dependency.get("type") in _SUCCESSOR_LINK_TYPES: + return True + return False + + def carrier_digest(carrier: dict[str, Any]) -> str: """Digest a carrier excluding its self-referential digest field.""" payload = dict(carrier) @@ -276,6 +292,8 @@ def validate_carrier( 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") @@ -521,6 +539,9 @@ def check_ci_metadata( file=sys.stderr, ) return 2 + if metadata.is_draft: + print("REFUSING CI pr-scope check: PR is draft; publish it before validation", file=sys.stderr) + return 2 base_source = fetch_base_validator_source(repository=repository, base_sha=metadata.base_sha) if base_source is not None: diff --git a/tests/unit/devtools/test_pr_scope.py b/tests/unit/devtools/test_pr_scope.py index 8d4cb7b4d3..132b0daef5 100644 --- a/tests/unit/devtools/test_pr_scope.py +++ b/tests/unit/devtools/test_pr_scope.py @@ -228,6 +228,32 @@ def test_ci_check_refuses_head_mismatch_before_fetching_base( fetch_base.assert_not_called() +def test_ci_check_refuses_draft_before_fetching_base( + monkeypatch: pytest.MonkeyPatch, + beads_path: Path, +) -> None: + metadata = pr_scope.PullRequestMetadata( + body=_body(_input(), beads_path), + head_sha=HEAD_SHA, + base_sha="b" * 40, + is_draft=True, + ) + fetch_base = MagicMock() + monkeypatch.setattr(pr_scope, "fetch_base_validator_source", fetch_base) + + assert ( + pr_scope.check_ci_metadata( + metadata, + repository="Sinity/polylogue", + beads_path=beads_path, + checkout_head_sha=HEAD_SHA, + expected_head_sha=HEAD_SHA, + ) + == 2 + ) + fetch_base.assert_not_called() + + def test_ci_check_executes_base_revision_validator( monkeypatch: pytest.MonkeyPatch, beads_path: Path, @@ -312,6 +338,33 @@ def test_check_rejects_closed_or_unknown_residual_successor( assert reason in result +def test_check_rejects_unlinked_residual_successor( + beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + records = [_record(ASSIGNED), _record(OPEN_SUCCESSOR)] + records[1]["dependencies"] = [ + {"issue_id": OPEN_SUCCESSOR, "depends_on_id": "polylogue-unrelated", "type": "relates-to"} + ] + beads_path.write_text("\n".join(json.dumps(record) for record in records) + "\n") + + result = _check(_body(_input("partial", [OPEN_SUCCESSOR]), beads_path), beads_path, tmp_path, capsys) + + assert result.startswith("1\n") + assert "has no durable Beads relationship" in result + + +def test_check_accepts_linked_residual_successor( + beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + records = [_record(ASSIGNED), _record(OPEN_SUCCESSOR)] + records[1]["dependencies"] = [{"issue_id": OPEN_SUCCESSOR, "depends_on_id": ASSIGNED, "type": "discovered-from"}] + beads_path.write_text("\n".join(json.dumps(record) for record in records) + "\n") + + result = _check(_body(_input("partial", [OPEN_SUCCESSOR]), beads_path), beads_path, tmp_path, capsys) + + assert result.startswith("0\n") + + def test_check_rejects_stale_canonical_beads_digest( beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: From 859b540522338d8322a2ea1e58bd07b626d1d850 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 6 Aug 2026 09:18:42 +0200 Subject: [PATCH 06/10] test(devtools): prove local base validator preference Problem: the authoritative checker prefers a validator from the checked-out base object, but that selection was not directly covered. What changed: add a focused test proving a locally available base validator is returned without a GitHub fallback. Ref polylogue-z7sv3 Co-Authored-By: Claude --- tests/unit/devtools/test_pr_scope.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/devtools/test_pr_scope.py b/tests/unit/devtools/test_pr_scope.py index 132b0daef5..d636a915d4 100644 --- a/tests/unit/devtools/test_pr_scope.py +++ b/tests/unit/devtools/test_pr_scope.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import subprocess from pathlib import Path from unittest.mock import MagicMock from urllib import request @@ -305,6 +306,23 @@ def test_ci_check_bootstraps_once_when_base_has_no_validator( ) +def test_fetch_base_validator_prefers_local_base_object( + monkeypatch: pytest.MonkeyPatch, +) -> None: + github_fetch = MagicMock() + monkeypatch.setattr(pr_scope, "_github_request_bytes", github_fetch) + monkeypatch.setattr( + subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess( + args=["git", "show"], returncode=0, stdout=b"base validator", stderr=b"" + ), + ) + + assert pr_scope.fetch_base_validator_source(repository="Sinity/polylogue", base_sha="b" * 40) == b"base validator" + github_fetch.assert_not_called() + + def test_check_rejects_carrier_bound_to_a_different_head_sha( beads_path: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: From 1a45ca2c312eda76cae10950e511f082f0bc8600 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 6 Aug 2026 09:25:33 +0200 Subject: [PATCH 07/10] fix(ci): avoid array syntax in scope gate Problem: the CircleCI scope-gate launcher used shell array syntax even though the job's shell contract is not part of the repository interface. What changed: branch explicitly between PR-number lookup and exact-head lookup, keeping both invocations portable while preserving the same strict arguments. Ref polylogue-z7sv3 Co-Authored-By: Claude --- .circleci/config.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7b257d709b..a8c92055e9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -77,14 +77,16 @@ jobs: if [ "${CIRCLE_BRANCH:-}" = "master" ]; then exit 0 fi - PR_ARGS=() if [ -n "${CIRCLE_PULL_REQUEST:-}" ]; then - PR_ARGS=(--pr "${CIRCLE_PULL_REQUEST##*/}") + ~/.local/bin/uv run devtools workspace pr-scope check-ci \ + --pr "${CIRCLE_PULL_REQUEST##*/}" \ + --repo "${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" \ + --expected-head-sha "${CIRCLE_SHA1}" + else + ~/.local/bin/uv run devtools workspace pr-scope check-ci \ + --repo "${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" \ + --expected-head-sha "${CIRCLE_SHA1}" fi - ~/.local/bin/uv run devtools workspace pr-scope check-ci \ - "${PR_ARGS[@]}" \ - --repo "${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" \ - --expected-head-sha "${CIRCLE_SHA1}" - run: name: devtools verify --quick command: ~/.local/bin/uv run devtools verify --quick From 14724cc0a0ffb9ef35c510d6db9c1419a154aa84 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 6 Aug 2026 09:36:52 +0200 Subject: [PATCH 08/10] fix(ci): bind scope gate to repository slug Problem: CircleCI project metadata is not a stable GitHub owner/repository contract for this project, so the scope gate could fail before reaching its validator. What changed: pass the explicit Sinity/polylogue repository slug to both PR metadata paths in the project-specific CircleCI job. Ref polylogue-z7sv3 Co-Authored-By: Claude --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a8c92055e9..bfdb5e4c7e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -80,11 +80,11 @@ jobs: if [ -n "${CIRCLE_PULL_REQUEST:-}" ]; then ~/.local/bin/uv run devtools workspace pr-scope check-ci \ --pr "${CIRCLE_PULL_REQUEST##*/}" \ - --repo "${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" \ + --repo "Sinity/polylogue" \ --expected-head-sha "${CIRCLE_SHA1}" else ~/.local/bin/uv run devtools workspace pr-scope check-ci \ - --repo "${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" \ + --repo "Sinity/polylogue" \ --expected-head-sha "${CIRCLE_SHA1}" fi - run: From 4b686ab21dd3539f3353a5ca6d69b39a32854b11 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 6 Aug 2026 09:44:39 +0200 Subject: [PATCH 09/10] ci: retain scope gate diagnostics Problem: CircleCI failures in the early gates were not inspectable without an authenticated Circle account. What changed: tee public-claims and structured-scope output into temporary logs and always upload those logs as job artifacts, preserving the command exit status. Ref polylogue-z7sv3 Co-Authored-By: Claude --- .circleci/config.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index bfdb5e4c7e..e38e7371bf 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -70,10 +70,11 @@ jobs: - mypy-v1- - run: name: Public claims gate - command: ~/.local/bin/uv run devtools verify public-claims --json + command: ~/.local/bin/uv run devtools verify public-claims --json | tee /tmp/polylogue-public-claims.json - run: name: Structured PR scope carrier command: | + set -o pipefail if [ "${CIRCLE_BRANCH:-}" = "master" ]; then exit 0 fi @@ -81,11 +82,11 @@ jobs: ~/.local/bin/uv run devtools workspace pr-scope check-ci \ --pr "${CIRCLE_PULL_REQUEST##*/}" \ --repo "Sinity/polylogue" \ - --expected-head-sha "${CIRCLE_SHA1}" + --expected-head-sha "${CIRCLE_SHA1}" 2>&1 | tee /tmp/polylogue-pr-scope.log else ~/.local/bin/uv run devtools workspace pr-scope check-ci \ --repo "Sinity/polylogue" \ - --expected-head-sha "${CIRCLE_SHA1}" + --expected-head-sha "${CIRCLE_SHA1}" 2>&1 | tee /tmp/polylogue-pr-scope.log fi - run: name: devtools verify --quick @@ -94,6 +95,14 @@ jobs: key: mypy-v1-{{ .Branch }}-{{ epoch }} paths: - .mypy_cache + - store_artifacts: + path: /tmp/polylogue-public-claims.json + destination: diagnostics + when: always + - store_artifacts: + path: /tmp/polylogue-pr-scope.log + destination: diagnostics + when: always lab-policies: docker: From 9d5585126018c73bddbea7ac405d63303bd73777 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 6 Aug 2026 09:50:27 +0200 Subject: [PATCH 10/10] docs: publish PR scope carrier before push Problem: CircleCI validates the carrier against the pushed head, so a body-only carrier update after push leaves the first run with stale metadata.\n\nWhat changed: Record the required order in the repository workflow: create the final commit, render its exact carrier, update the PR body, verify the carrier, then push.\n\nCompatibility/migration: This changes coordinator procedure only.\n\nRef polylogue-z7sv3\n\nCo-Authored-By: Claude --- CLAUDE.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6c7abf2fc9..8ec1019b7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -383,7 +383,10 @@ workflow, not optional conveniences — use them at the point named, every time: 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. + 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. 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.