diff --git a/.claude/loaders/cursor-daily-ai-engineer.md b/.claude/loaders/cursor-daily-ai-engineer.md index 732a8714..4bb4c6ad 100644 --- a/.claude/loaders/cursor-daily-ai-engineer.md +++ b/.claude/loaders/cursor-daily-ai-engineer.md @@ -80,13 +80,13 @@ protocol cannot arbitrate across lanes. > `portfolio-maintenance` preflight `cd`s to a machine-local Mac checkout that does not exist here; > following it literally would stop your run before it starts. Use your workspace root and verify it > the same way (`test -d docs && test -f .gitmodules`). -> - **You cannot self-assign — your claim is the branch plus the PR.** The claim protocol's -> self-assignment step returns 403 for `app/cursor`. That is a measured exception, not licence to -> skip claiming: push `cursor/--` with a real commit and open the draft PR -> promptly, since the PR body's `#` reference is the only durable claim signal you can emit. -> Note that cross-lane races are not currently arbitrated at all -> ([#2302](https://github.com/devantler-tech/monorepo/issues/2302)), so check open PRs and remote -> branches carefully before selecting. +> - **You cannot self-assign — acquire `agent-claim/` then push the lane branch.** The claim +> protocol's self-assignment step returns 403 for `app/cursor`. That is a measured exception, not +> licence to skip claiming: acquire the lane-neutral tip with +> `.claude/scripts/agent-claim.sh acquire ` (this is your cross-lane signal — siblings can +> see it without an assignee), push `cursor/--` with a real commit, and open the +> draft PR promptly so you can `agent-claim.sh retire `. The PR body's `#` reference +> remains the durable post-PR signal. > - **File discovered issues normally — a local run will board them.** You *can* create issues; you > cannot add them to project 5 (`board-add` is 403). An unboarded issue is a fixable gap, whereas a > finding recorded only in your run output is **lost**, because nothing local consumes that. So file @@ -130,11 +130,11 @@ issue and Projects `board-add` all return 403** (`Resource not accessible by int Two consequences for this loader, both live now: - **The claim protocol's self-assign step is unavailable to this instance.** It cannot assign issues, - so its claim rests entirely on the pushed `cursor/*` branch plus the PR body's issue reference — - which is why it must open its draft PR promptly rather than building in the dark. Note that - cross-lane races are not arbitrated at all today - ([#2302](https://github.com/devantler-tech/monorepo/issues/2302)), so this instance is the most - exposed of the three: it has the weakest claim signal *and* no arbitration behind it. + so its claim rests on the lane-neutral `agent-claim/` tip (via `agent-claim.sh`) plus the + pushed `cursor/*` branch plus the PR body's issue reference — which is why it must open its draft + PR promptly and retire the tip. Cross-lane arbitration is the shared tip + ([#2302](https://github.com/devantler-tech/monorepo/issues/2302)); assignment remains a local-lane + lease signal only. - **It cannot request reviews or reply to threads**, so it cannot clear the hygiene pentad on its own drafts or satisfy the green-review gate for anything. diff --git a/.claude/scripts/agent-claim.sh b/.claude/scripts/agent-claim.sh new file mode 100755 index 00000000..5a6e4043 --- /dev/null +++ b/.claude/scripts/agent-claim.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +# +# agent-claim.sh — lane-neutral claim arbitration for multi-instance races +# (monorepo#2302). +# +# Every agent instance writes its own work-branch namespace (`claude/*`, +# `codex/*`, `cursor/*`), so a race settled on the work-branch name is never +# arbitrated across lanes. This helper pushes a SINGLE shared ref +# `agent-claim/` that every instance derives from the issue number +# alone, BEFORE creating its lane-specific work branch. The push decides the +# race; the tip comparison (never the push's exit status) decides the winner. +# +# Four traps, all proven before this script existed — do not regress them: +# 1. Second non-force push is refused; ls-remote returns the winner's sha. +# 2. `git push … | tail` exits 0 on a REJECTED push — only the tip compare +# is safe (never judge by exit status, never through a pipe). +# 3. Identical claim commits collide: every instance may commit as the same +# login with the same message on the same parent in the same second, +# yielding a byte-identical sha so BOTH pushes succeed. A portable nonce +# in the message makes the commits distinct; fail closed when no entropy +# source is available. +# 4. An unretired claim ref is a permanent lock (nothing sweeps +# `agent-claim/*`). Retire on PR open; stale takeover needs BOTH evidence +# gates (no open PR for the issue AND tip older than the lease). +# +# Usage: +# agent-claim.sh acquire [--remote NAME] [--repo-dir DIR] +# [--lease-hours N] [--takeover] +# agent-claim.sh verify [--remote NAME] [--repo-dir DIR] +# agent-claim.sh tip [--remote NAME] [--repo-dir DIR] +# agent-claim.sh retire [--remote NAME] [--repo-dir DIR] +# agent-claim.sh is-stale [--remote NAME] [--repo-dir DIR] +# [--lease-hours N] +# +# Exit codes: +# 0 success (acquired / tip matches / retired / is stale) +# 1 lost the race / tip mismatch / not stale +# 2 usage error / missing entropy / unsafe arguments +set -Eeuo pipefail + +DEFAULT_REMOTE="origin" +DEFAULT_LEASE_HOURS=2 +REF_PREFIX="refs/heads/agent-claim" + +fail() { echo "agent-claim: $*" >&2; exit 2; } + +usage() { + sed -n '/^# Usage:/,/^# Exit codes:/p' "$0" | sed 's/^# \{0,1\}//' | head -n -1 +} + +# Portable entropy — fail closed when none is available (trap 3). Prefer +# /dev/urandom (Linux + macOS); never soft-fail back to a fixed message. +claim_nonce() { + if [[ -r /dev/urandom ]]; then + # 16 bytes hex, no whitespace. `od` is POSIX; tr strips the spaces od + # inserts between bytes on some platforms. + od -An -N16 -tx1 /dev/urandom | tr -d ' \n' + return 0 + fi + fail "no portable entropy source (/dev/urandom unreadable); refusing to claim with a fixed message (trap 3)" +} + +# Resolve the claim ref name for an issue number. Caller must already have +# validated the issue via require_issue — this only formats. +claim_ref() { + printf '%s/%s' "$REF_PREFIX" "$1" +} + +# Short name used by git push (heads/… without refs/). +claim_branch() { + printf 'agent-claim/%s' "$1" +} + +# Validate issue BEFORE any command substitution — `exit` inside $(…) only +# kills the subshell, so a bad issue would otherwise soft-fail into exit 1. +require_issue() { + [[ "$1" =~ ^[0-9]+$ ]] || fail "issue must be a positive integer (got '$1')" +} + +# git wrapper scoped to --repo-dir when set. +git_c() { + if [[ -n "${REPO_DIR}" ]]; then + git -C "$REPO_DIR" "$@" + else + git "$@" + fi +} + +# Read the remote tip of the claim ref. Empty string when the ref is absent. +remote_tip() { + local issue="$1" + local ref + ref="$(claim_ref "$issue")" + # Match exact ref; awk prints the sha only. No pipe after push elsewhere. + git_c ls-remote "$REMOTE" "$ref" | awk '{print $1; exit}' +} + +# Committer unix time of a remote tip, via a local fetch of that single ref. +# Used only for the stale-takeover evidence gate (trap 4). +tip_committer_unix() { + local issue="$1" + local tip="$2" + local tmp_ref="refs/agent-claim-probe/${issue}-$$" + # Fetch the tip into a throwaway local ref so we can read its committer date + # without checking it out. Clean up either way. + if ! git_c fetch --quiet "$REMOTE" "+${tip}:${tmp_ref}" 2>/dev/null; then + git_c update-ref -d "$tmp_ref" 2>/dev/null || true + return 1 + fi + local ts + ts="$(git_c log -1 --format=%ct "$tmp_ref" 2>/dev/null || true)" + git_c update-ref -d "$tmp_ref" 2>/dev/null || true + [[ -n "$ts" ]] || return 1 + printf '%s' "$ts" +} + +cmd_tip() { + local issue="$1" + require_issue "$issue" + local tip + tip="$(remote_tip "$issue")" + if [[ -z "$tip" ]]; then + echo "agent-claim: no tip for $(claim_branch "$issue")" >&2 + exit 1 + fi + printf '%s\n' "$tip" +} + +cmd_verify() { + local issue="$1" + local expected="$2" + require_issue "$issue" + [[ "$expected" =~ ^[0-9a-f]{7,40}$ ]] || fail "expected-sha looks invalid: '$expected'" + local tip + tip="$(remote_tip "$issue")" + if [[ -z "$tip" ]]; then + echo "agent-claim: LOST — $(claim_branch "$issue") is absent (expected $expected)" >&2 + exit 1 + fi + # Accept exact match or unambiguous prefix (abbreviated expected vs full tip). + if [[ "$tip" == "$expected" || "$tip" == "$expected"* ]]; then + printf '%s\n' "$tip" + exit 0 + fi + echo "agent-claim: LOST — tip $tip is not yours (expected $expected)" >&2 + exit 1 +} + +# Returns 0 when the tip is past the lease, 1 when live/absent. Does not exit +# the process — callers that need a CLI exit use cmd_is_stale. +claim_is_stale() { + local issue="$1" + local tip + tip="$(remote_tip "$issue")" + [[ -n "$tip" ]] || return 1 + local ts now age_hours + ts="$(tip_committer_unix "$issue" "$tip")" || return 1 + now="$(date -u +%s)" + age_hours=$(( (now - ts) / 3600 )) + (( age_hours >= LEASE_HOURS )) +} + +cmd_is_stale() { + local issue="$1" + require_issue "$issue" + local tip + tip="$(remote_tip "$issue")" + if [[ -z "$tip" ]]; then + echo "agent-claim: no claim tip — nothing to judge stale" >&2 + exit 1 + fi + local ts now age_hours + ts="$(tip_committer_unix "$issue" "$tip")" || fail "could not read committer date for tip $tip" + now="$(date -u +%s)" + age_hours=$(( (now - ts) / 3600 )) + if (( age_hours >= LEASE_HOURS )); then + echo "agent-claim: STALE — tip $tip age=${age_hours}h lease=${LEASE_HOURS}h" + exit 0 + fi + echo "agent-claim: LIVE — tip $tip age=${age_hours}h lease=${LEASE_HOURS}h" >&2 + exit 1 +} + +cmd_retire() { + local issue="$1" + require_issue "$issue" + local branch + branch="$(claim_branch "$issue")" + local tip + tip="$(remote_tip "$issue")" + if [[ -z "$tip" ]]; then + echo "agent-claim: nothing to retire — ${branch} absent" + exit 0 + fi + # Delete the ref. If it is already gone, treat as success (idempotent). + if ! git_c push --quiet "$REMOTE" ":${branch}" 2>/tmp/agent-claim-retire-$$.err; then + local err + err="$(cat /tmp/agent-claim-retire-$$.err 2>/dev/null || true)" + rm -f /tmp/agent-claim-retire-$$.err + if [[ -z "$(remote_tip "$issue")" ]]; then + echo "agent-claim: retired ${branch} (already absent)" + exit 0 + fi + fail "retire of ${branch} failed: ${err:-unknown error}" + fi + rm -f /tmp/agent-claim-retire-$$.err + echo "agent-claim: retired ${branch} (was $tip)" +} + +cmd_acquire() { + local issue="$1" + require_issue "$issue" + local branch tip existing + branch="$(claim_branch "$issue")" + + existing="$(remote_tip "$issue")" + if [[ -n "$existing" ]]; then + if [[ "$TAKEOVER" -eq 1 ]]; then + # Evidence gate 1 of 2 (caller must have confirmed no open PR). Gate 2: + # tip past the lease. Refuse takeover of a live claim. + if ! claim_is_stale "$issue"; then + echo "agent-claim: REFUSED takeover — claim is still within the ${LEASE_HOURS}h lease (tip $existing)" >&2 + exit 1 + fi + echo "agent-claim: taking over stale claim ${branch} (tip $existing)" + # Delete the stale tip first so the subsequent non-force push can land. + # Never --force onto a live tip — only delete after is-stale. + git_c push --quiet "$REMOTE" ":${branch}" \ + || fail "could not delete stale ${branch} before takeover" + else + echo "agent-claim: LOST — ${branch} already held at $existing (pass --takeover after confirming no open PR + lease expiry)" >&2 + exit 1 + fi + fi + + local nonce parent sha + nonce="$(claim_nonce)" + # Anchor on the remote default branch tip when available so two acquirers + # share a parent (the condition that makes trap 3 reproducible). Fall back + # to HEAD when offline / no remote default. + parent="$(git_c ls-remote "$REMOTE" HEAD 2>/dev/null | awk '{print $1; exit}')" + if [[ -z "$parent" ]]; then + parent="$(git_c rev-parse HEAD)" + fi + + # Empty commit with a UNIQUE message (nonce). Identical messages on the same + # parent in the same second produce byte-identical commits (trap 3) — the + # nonce is the whole defence. + sha="$(git_c commit-tree "$parent^{tree}" -p "$parent" -m "chore: agent-claim #${issue} nonce=${nonce}")" + + # Push WITHOUT force. Capture status ourselves — never through a pipe + # (trap 2: `push | tail` reports tail's status, so a rejection reads as 0). + local push_rc=0 + git_c push --quiet "$REMOTE" "${sha}:refs/heads/${branch}" || push_rc=$? + + tip="$(remote_tip "$issue")" + if [[ -z "$tip" ]]; then + echo "agent-claim: LOST — push produced no tip for ${branch} (push_rc=${push_rc})" >&2 + exit 1 + fi + if [[ "$tip" != "$sha" ]]; then + echo "agent-claim: LOST — tip ${tip} is not ours (ours=${sha}, push_rc=${push_rc})" >&2 + exit 1 + fi + # Tip matches ours — we won, regardless of push_rc (defensive: a weird + # transport that exits non-zero after a successful update still counts). + echo "agent-claim: WON ${branch} tip=${sha}" + printf '%s\n' "$sha" +} + +# --------------------------------------------------------------------------- +# argv +# --------------------------------------------------------------------------- +REMOTE="$DEFAULT_REMOTE" +REPO_DIR="" +LEASE_HOURS="$DEFAULT_LEASE_HOURS" +TAKEOVER=0 +COMMAND="" +ISSUE="" +EXPECTED_SHA="" + +while [[ $# -gt 0 ]]; do + case "$1" in + acquire|verify|tip|retire|is-stale) + [[ -z "$COMMAND" ]] || fail "command already set to '$COMMAND'" + COMMAND="$1"; shift + ;; + --remote) REMOTE="${2-}"; shift 2 || fail "--remote needs a value" ;; + --repo-dir) REPO_DIR="${2-}"; shift 2 || fail "--repo-dir needs a value" ;; + --lease-hours) LEASE_HOURS="${2-}"; shift 2 || fail "--lease-hours needs a value" ;; + --takeover) TAKEOVER=1; shift ;; + -h|--help) usage; exit 0 ;; + -*) fail "unknown flag '$1'" ;; + *) + if [[ -z "$ISSUE" ]]; then + ISSUE="$1"; shift + elif [[ "$COMMAND" == "verify" && -z "$EXPECTED_SHA" ]]; then + EXPECTED_SHA="$1"; shift + else + fail "unexpected argument '$1'" + fi + ;; + esac +done + +[[ -n "$COMMAND" ]] || { usage >&2; exit 2; } +[[ -n "$ISSUE" ]] || fail "issue number required" +[[ "$LEASE_HOURS" =~ ^[0-9]+$ ]] || fail "--lease-hours must be a non-negative integer" + +case "$COMMAND" in + acquire) cmd_acquire "$ISSUE" ;; + verify) + [[ -n "$EXPECTED_SHA" ]] || fail "verify requires " + cmd_verify "$ISSUE" "$EXPECTED_SHA" + ;; + tip) cmd_tip "$ISSUE" ;; + retire) cmd_retire "$ISSUE" ;; + is-stale) cmd_is_stale "$ISSUE" ;; + *) fail "unknown command '$COMMAND'" ;; +esac diff --git a/.claude/scripts/agent-claim.test.sh b/.claude/scripts/agent-claim.test.sh new file mode 100755 index 00000000..7881cc87 --- /dev/null +++ b/.claude/scripts/agent-claim.test.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# +# Self-test for agent-claim.sh — RED/GREEN coverage of the four traps proven +# in monorepo#2302. Fixtures use a local bare remote + two clones; nothing +# touches a real network remote. +# +# Trap 1 — arbitration works: second non-force push loses; ls-remote shows winner. +# Trap 2 — never judge by push exit status: a piped `push | true` looks like +# success while verify correctly reports LOST. +# Trap 3 — identical commits collide without a nonce; the helper's nonce makes +# two acquirers produce distinct shas. +# Trap 4 — unretired claim is a permanent lock; --takeover after lease expiry +# recovers it, and a third acquirer still loses to the takeover winner. +set -Eeuo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +tool="$script_dir/agent-claim.sh" +chmod +x "$tool" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +failures=0 +pass() { printf 'ok — %s\n' "$1"; } +fail() { printf 'FAIL — %s\n' "$1"; failures=$(( failures + 1 )); } + +check() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then pass "$desc"; else + fail "$desc (expected '$expected', got '$actual')" + fi +} + +run() { + local rc=0 + "$@" >/dev/null 2>&1 || rc=$? + echo "$rc" +} + +# --------------------------------------------------------------------------- +# Fixture: bare remote + seed commit, then two clones sharing that remote. +# --------------------------------------------------------------------------- +bare="$tmp/remote.git" +git init --bare --quiet "$bare" + +seed="$tmp/seed" +git clone --quiet "$bare" "$seed" +git -C "$seed" config user.email "agent-claim-test@example.com" +git -C "$seed" config user.name "agent-claim-test" +# One shared parent so trap 3 is reproducible (identical parent + message → same sha). +echo seed > "$seed/README" +git -C "$seed" add README +git -C "$seed" commit --quiet -m "chore: seed" +git -C "$seed" push --quiet origin HEAD:main +git -C "$seed" symbolic-ref HEAD refs/heads/main 2>/dev/null || true +git --git-dir="$bare" symbolic-ref HEAD refs/heads/main + +clone_a="$tmp/a" +clone_b="$tmp/b" +clone_c="$tmp/c" +git clone --quiet "$bare" "$clone_a" +git clone --quiet "$bare" "$clone_b" +git clone --quiet "$bare" "$clone_c" +for c in "$clone_a" "$clone_b" "$clone_c"; do + git -C "$c" config user.email "agent-claim-test@example.com" + git -C "$c" config user.name "agent-claim-test" +done + +ISSUE=2302 + +# --------------------------------------------------------------------------- +# Trap 1 — A wins, B loses. Tip equals A's sha. +# --------------------------------------------------------------------------- +out_a="$tmp/out-a" +rc_a=0 +"$tool" acquire "$ISSUE" --repo-dir "$clone_a" --remote origin >"$out_a" 2>"$tmp/err-a" || rc_a=$? +sha_a="$(tail -n1 "$out_a")" +check "trap1: A acquire exits 0" "0" "$rc_a" +[[ "$sha_a" =~ ^[0-9a-f]{40}$ ]] && pass "trap1: A printed a full sha" || fail "trap1: A sha missing ($sha_a)" + +rc_b=0 +"$tool" acquire "$ISSUE" --repo-dir "$clone_b" --remote origin >"$tmp/out-b" 2>"$tmp/err-b" || rc_b=$? +check "trap1: B acquire exits 1 (lost)" "1" "$rc_b" + +tip="$(git -C "$clone_a" ls-remote origin "refs/heads/agent-claim/${ISSUE}" | awk '{print $1}')" +check "trap1: remote tip equals A's sha" "$sha_a" "$tip" + +rc_v=0 +"$tool" verify "$ISSUE" "$sha_a" --repo-dir "$clone_a" --remote origin >/dev/null 2>&1 || rc_v=$? +check "trap1: A verify exits 0" "0" "$rc_v" + +rc_vb=0 +"$tool" verify "$ISSUE" "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" --repo-dir "$clone_b" --remote origin >/dev/null 2>&1 || rc_vb=$? +check "trap1: B verify of foreign sha exits 1" "1" "$rc_vb" + +# --------------------------------------------------------------------------- +# Trap 2 — never judge by push exit status. Simulate the classic +# `git push … | true` shape: the pipeline exits 0 even when the push is a +# no-op / rejection, but verify against B's hoped-for sha still says LOST. +# --------------------------------------------------------------------------- +# B crafts its own claim commit and "pushes" through a pipe that swallows +# the rejection, then checks the tip — the only safe signal. +nonce_b="trap2-fixed-nonce-should-lose" +parent="$(git -C "$clone_b" ls-remote origin HEAD | awk '{print $1}')" +sha_b="$(git -C "$clone_b" commit-tree "${parent}^{tree}" -p "$parent" \ + -m "chore: agent-claim #${ISSUE} nonce=${nonce_b}")" +# Push through a pipe ending in `true` — exit status of the pipeline is 0 +# regardless of whether the push updated the tip (trap 2 reproduction). +set +e +git -C "$clone_b" push origin "${sha_b}:refs/heads/agent-claim/${ISSUE}" 2>/dev/null | true +pipe_rc=${PIPESTATUS[0]} # push's own status (may be non-zero) +set -e +# The PIPELINE as a whole with `| true` would be 0; we assert the tip check +# is what matters, not either status. +rc_trap2=0 +"$tool" verify "$ISSUE" "$sha_b" --repo-dir "$clone_b" --remote origin >/dev/null 2>&1 || rc_trap2=$? +check "trap2: verify rejects B's sha even after a pipe-masked push" "1" "$rc_trap2" +tip_after="$(git -C "$clone_b" ls-remote origin "refs/heads/agent-claim/${ISSUE}" | awk '{print $1}')" +check "trap2: tip still A's (pipe push did not steal the claim)" "$sha_a" "$tip_after" +# Sanity: document that a bare push status alone is not the verdict we use. +pass "trap2: push_rc_observed=${pipe_rc} (ignored by protocol; tip compare is authoritative)" + +# --------------------------------------------------------------------------- +# Trap 3 — without a nonce, identical commits collide. Prove the collision +# first (RED), then prove the helper's nonce avoids it (GREEN). +# --------------------------------------------------------------------------- +# Fresh issue number so we start from an empty claim ref. +ISSUE3=9303 +parent3="$(git -C "$clone_a" ls-remote origin HEAD | awk '{print $1}')" +# Same author, same message, same parent, same second → identical sha. +export GIT_AUTHOR_NAME="agent-claim-test" +export GIT_AUTHOR_EMAIL="agent-claim-test@example.com" +export GIT_COMMITTER_NAME="agent-claim-test" +export GIT_COMMITTER_EMAIL="agent-claim-test@example.com" +export GIT_AUTHOR_DATE="2026-07-20T12:00:00Z" +export GIT_COMMITTER_DATE="2026-07-20T12:00:00Z" +fixed_msg="chore: agent-claim #${ISSUE3}" +sha_left="$(git -C "$clone_a" commit-tree "${parent3}^{tree}" -p "$parent3" -m "$fixed_msg")" +sha_right="$(git -C "$clone_b" commit-tree "${parent3}^{tree}" -p "$parent3" -m "$fixed_msg")" +check "trap3 RED: identical inputs produce identical sha" "$sha_left" "$sha_right" +# Both pushes of the SAME sha succeed (fast-forward / identical update) — the +# silent double-win that made cross-lane arbitration fail. +git -C "$clone_a" push --quiet origin "${sha_left}:refs/heads/agent-claim/${ISSUE3}" +git -C "$clone_b" push --quiet origin "${sha_right}:refs/heads/agent-claim/${ISSUE3}" +tip3="$(git -C "$clone_a" ls-remote origin "refs/heads/agent-claim/${ISSUE3}" | awk '{print $1}')" +check "trap3 RED: both writers see the tip as 'theirs'" "$sha_left" "$tip3" +# Clean up the RED fixture before the GREEN run. +git -C "$clone_a" push --quiet origin ":agent-claim/${ISSUE3}" + +# GREEN: helper acquire twice → distinct shas; second loses. +unset GIT_AUTHOR_DATE GIT_COMMITTER_DATE +ISSUE3G=9304 +rc_a3=0 +out_a3="$tmp/out-a3" +"$tool" acquire "$ISSUE3G" --repo-dir "$clone_a" --remote origin >"$out_a3" 2>"$tmp/err-a3" || rc_a3=$? +sha_a3="$(tail -n1 "$out_a3")" +check "trap3 GREEN: first acquire exits 0" "0" "$rc_a3" +rc_b3=0 +"$tool" acquire "$ISSUE3G" --repo-dir "$clone_b" --remote origin >"$tmp/out-b3" 2>"$tmp/err-b3" || rc_b3=$? +check "trap3 GREEN: second acquire exits 1" "1" "$rc_b3" +# Extract any sha the loser may have printed (should not match winner). +# More importantly: crafting two helper commits back-to-back must differ. +nonce_x="$("$tool" acquire 999001 --repo-dir "$clone_a" --remote origin 2>/dev/null | tail -n1 || true)" +# Retire the throwaway so it does not leak into later assertions. +"$tool" retire 999001 --repo-dir "$clone_a" --remote origin >/dev/null 2>&1 || true +# Two sequential commit-tree calls WITH distinct nonces must differ — mirror +# what the helper does internally. +n1="$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')" +n2="$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')" +s1="$(git -C "$clone_a" commit-tree "${parent3}^{tree}" -p "$parent3" -m "chore: agent-claim #x nonce=${n1}")" +s2="$(git -C "$clone_a" commit-tree "${parent3}^{tree}" -p "$parent3" -m "chore: agent-claim #x nonce=${n2}")" +if [[ "$s1" != "$s2" ]]; then + pass "trap3 GREEN: distinct nonces produce distinct shas" +else + fail "trap3 GREEN: distinct nonces still collided ($s1)" +fi +check "trap3 GREEN: winner tip stable at first sha" "$sha_a3" \ + "$(git -C "$clone_a" ls-remote origin "refs/heads/agent-claim/${ISSUE3G}" | awk '{print $1}')" + +# --------------------------------------------------------------------------- +# Trap 4 — unretired claim locks forever; stale takeover recovers; third loses. +# --------------------------------------------------------------------------- +ISSUE4=9404 +# A acquires then "crashes" (no retire). +rc_a4=0 +out_a4="$tmp/out-a4" +"$tool" acquire "$ISSUE4" --repo-dir "$clone_a" --remote origin >"$out_a4" 2>"$tmp/err-a4" || rc_a4=$? +sha_a4="$(tail -n1 "$out_a4")" +check "trap4: A acquire exits 0" "0" "$rc_a4" + +# B tries immediately — refused (live lease). +rc_b4=0 +"$tool" acquire "$ISSUE4" --repo-dir "$clone_b" --remote origin --takeover --lease-hours 2 \ + >"$tmp/out-b4" 2>"$tmp/err-b4" || rc_b4=$? +check "trap4: B takeover within lease exits 1" "1" "$rc_b4" +check "trap4: tip still A's after refused takeover" "$sha_a4" \ + "$(git -C "$clone_a" ls-remote origin "refs/heads/agent-claim/${ISSUE4}" | awk '{print $1}')" + +# B takes over with lease-hours 0 (tip age 0h >= 0 → stale). Stands in for +# "no open PR + tip past lease" after the caller checked the PR side. +rc_b4b=0 +out_b4b="$tmp/out-b4b" +"$tool" acquire "$ISSUE4" --repo-dir "$clone_b" --remote origin --takeover --lease-hours 0 \ + >"$out_b4b" 2>"$tmp/err-b4b" || rc_b4b=$? +sha_b4="$(tail -n1 "$out_b4b")" +check "trap4: B stale takeover exits 0" "0" "$rc_b4b" +check "trap4: tip equals B after takeover" "$sha_b4" \ + "$(git -C "$clone_b" ls-remote origin "refs/heads/agent-claim/${ISSUE4}" | awk '{print $1}')" +if [[ "$sha_b4" != "$sha_a4" ]]; then + pass "trap4: takeover sha differs from crashed A's" +else + fail "trap4: takeover reused A's sha" +fi + +# C still loses to B. +rc_c4=0 +"$tool" acquire "$ISSUE4" --repo-dir "$clone_c" --remote origin >"$tmp/out-c4" 2>"$tmp/err-c4" || rc_c4=$? +check "trap4: C acquire exits 1 (lost to B)" "1" "$rc_c4" +check "trap4: tip still B's after C loses" "$sha_b4" \ + "$(git -C "$clone_c" ls-remote origin "refs/heads/agent-claim/${ISSUE4}" | awk '{print $1}')" + +# Retire is idempotent and clears the lock. +rc_r=0 +"$tool" retire "$ISSUE4" --repo-dir "$clone_b" --remote origin >/dev/null 2>&1 || rc_r=$? +check "trap4: retire exits 0" "0" "$rc_r" +tip_gone="$(git -C "$clone_b" ls-remote origin "refs/heads/agent-claim/${ISSUE4}" | awk '{print $1}')" +check "trap4: tip absent after retire" "" "$tip_gone" +rc_r2=0 +"$tool" retire "$ISSUE4" --repo-dir "$clone_b" --remote origin >/dev/null 2>&1 || rc_r2=$? +check "trap4: retire is idempotent" "0" "$rc_r2" + +# --------------------------------------------------------------------------- +# Entropy fail-closed: if /dev/urandom were unreadable the helper must exit 2. +# We cannot unmount /dev/urandom here; instead assert the nonce function's +# contract by checking the script refuses a non-integer issue (usage fail-closed). +# --------------------------------------------------------------------------- +rc_bad=0 +"$tool" acquire not-a-number --repo-dir "$clone_a" --remote origin >/dev/null 2>&1 || rc_bad=$? +check "usage: non-integer issue exits 2" "2" "$rc_bad" + +# --------------------------------------------------------------------------- +if (( failures > 0 )); then + printf '\n%d failure(s)\n' "$failures" >&2 + exit 1 +fi +printf '\nall agent-claim traps passed\n' +exit 0 diff --git a/.claude/skills/portfolio-maintenance/SKILL.md b/.claude/skills/portfolio-maintenance/SKILL.md index 106afec0..75470ef1 100644 --- a/.claude/skills/portfolio-maintenance/SKILL.md +++ b/.claude/skills/portfolio-maintenance/SKILL.md @@ -454,15 +454,19 @@ backlog. Use the [`product-engineering`](../product-engineering/SKILL.md) skill; **Only the agent account's assignment is a claim, and only it expires:** an issue assigned to a **human collaborator** (or `Copilot`) is someone else's work-in-progress — respect it and pick a different issue, never take it over on this window. **Claim - before you build:** self-assign + push the branch **with the issue number in its name** the moment - you select — and if `devantler` is ALREADY assigned (a stale bare assignment from an abandoned run), - **remove then re-add**, since adding an existing assignee is a no-op that would leave your lease - carrying the old timestamp. **The push decides the race:** put a real commit on the claim branch - (never a bare base pointer), push without force, then confirm `git ls-remote` shows YOUR sha — two - instances derive the same branch name, so a rejected push or someone else's tip means you lost; - stand down rather than force over them. Check open PRs, remote - `claude/*` branches AND assignees by **issue number, never literal branch name**. A live claim - (assigned + branched, in-window, no PR) is skip reason **(e)** — the only one that expires by + before you build — lane-neutral tip FIRST:** acquire `agent-claim/` via + `.claude/scripts/agent-claim.sh acquire ` (cross-lane race; LOST → stand down), then + self-assign when your identity can (and if `devantler` is ALREADY assigned, **remove then + re-add**, since adding an existing assignee is a no-op that would leave your lease carrying the + old timestamp), then push the lane work branch **with the issue number in its name**. **The + shared tip decides the race:** the helper writes a nonced commit, pushes without force, and + verifies `git ls-remote` shows YOUR sha — never judge by the push's exit status or through a + pipe (`push | tail` reports `tail`'s 0 on a rejection). Retire the tip when the draft PR opens + (`agent-claim.sh retire `); a tip with no open PR past the ~2h lease may be taken over + with `--takeover` only after confirming no open `#` PR. Check open PRs, remote + `agent-claim/` tips, lane work branches (`claude/*`/`codex/*`/`cursor/*`) AND assignees by + **issue number, never literal branch name**. A live claim (shared tip in-window, or assigned + + branched in-window, no PR) is skip reason **(e)** — the only one that expires by itself. If it **already has an actionable trusted-author, non-draft PR**, drive *that* to merge instead of duplicating; leave automation-owned dependency PRs to repository automation, **draft** PRs for the maintainer, and diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 00d8b0fc..ea2d78c7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,6 +33,7 @@ jobs: board-add: ${{ steps.filter.outputs.board-add }} flow-scorecard: ${{ steps.filter.outputs.flow-scorecard }} memory-hygiene: ${{ steps.filter.outputs.memory-hygiene }} + agent-claim: ${{ steps.filter.outputs.agent-claim }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -97,6 +98,12 @@ jobs: # Self-gate on the workflow too, so a change that breaks this # job's own wiring still runs the test it gates. - '.github/workflows/ci.yaml' + agent-claim: + - '.claude/scripts/agent-claim.sh' + - '.claude/scripts/agent-claim.test.sh' + # Self-gate on the workflow too, so a change that breaks this + # job's own wiring still runs the test it gates. + - '.github/workflows/ci.yaml' maintainer-preflight: - '.claude/skills/portfolio-maintenance/SKILL.md' - '.claude/scripts/maintainer-preflight.test.sh' @@ -404,6 +411,28 @@ jobs: - name: Verify the memory hygiene guard run: bash .claude/scripts/memory-hygiene.test.sh + test-agent-claim: + name: Test agent-claim arbitration + needs: changes + if: needs.changes.outputs.agent-claim == 'true' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Hermetic bare-origin fixture — no network, no token. Pins the four + # cross-lane claim traps (monorepo#2302): push arbitration, tip-compare + # not exit-status, nonce collision avoidance, and stale takeover/retire. + - name: Self-test agent-claim traps + run: bash .claude/scripts/agent-claim.test.sh + test-product-value-contract: name: Test product value contract needs: changes @@ -528,6 +557,7 @@ jobs: - test-submodule-init - test-maintainer-preflight - test-memory-hygiene + - test-agent-claim - test-self-review-contract - test-review-provider-loop-contract - test-agent-role-delivery-contract @@ -552,6 +582,7 @@ jobs: ${{ needs.test-submodule-init.result }} ${{ needs.test-maintainer-preflight.result }} ${{ needs.test-memory-hygiene.result }} + ${{ needs.test-agent-claim.result }} ${{ needs.test-self-review-contract.result }} ${{ needs.test-review-provider-loop-contract.result }} ${{ needs.test-agent-role-delivery-contract.result }} diff --git a/AGENTS.md b/AGENTS.md index 9c668012..560eba33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,8 +208,8 @@ source of truth is version-controlled at [`.claude/loaders/cursor-daily-ai-engineer.md`](.claude/loaders/cursor-daily-ai-engineer.md) and re-pasted into the Automations UI on change. **Each instance owns its own branch namespace** — `claude/*`, `codex/*`, `cursor/*` — which is what keeps draft ownership and the per-tick branch sweep -from crossing lanes. It is also why claim arbitration does **not** work across lanes today: see the -cross-lane limit in *Claim protocol* rule 4. +from crossing lanes. Cross-lane claim races are arbitrated on the shared `agent-claim/` tip +(see *Claim protocol*), acquired before the lane work branch. ### Agentic engineering plugin contract This deployment **consumes** the `agentic-engineering` plugin from @@ -569,11 +569,21 @@ and a pushed branch), so the *Professional-work repository boundary* below still claim, probe, or push anywhere that boundary has not been cleared, and nothing here licenses a first touch of an unconfirmed repo: -1. **Check three signals before selecting, not one:** open PRs, remote `claude/*` branches, and issue - assignees. An assignee here means "an instance has claimed this", **not** "the human maintainer - took it" — every instance commits and assigns as `devantler` (see *Trust gate*), so the login - cannot distinguish one instance from another or from him. Read it as a claim, never as a - hands-off signal, and never let it park an issue past the expiry below. +**Cross-lane arbitration uses a lane-neutral ref.** Each instance still writes its own work-branch +namespace (`claude/*`, `codex/*`, `cursor/*`), so a race settled only on the work-branch name is never +arbitrated across lanes. The durable claim is therefore `agent-claim/` — a single shared ref +every instance derives from the issue number alone — acquired **before** the lane-specific work +branch via [`.claude/scripts/agent-claim.sh`](.claude/scripts/agent-claim.sh) (RED/GREEN coverage of +the four proven traps lives in `agent-claim.test.sh`). + +1. **Check four signals before selecting, not one:** open PRs, remote `agent-claim/` tips, + remote lane work branches (`claude/*` / `codex/*` / `cursor/*`), and issue assignees. An assignee + here means "an instance has claimed this", **not** "the human maintainer took it" — every instance + that can assign does so as `devantler` (see *Trust gate*), so the login cannot distinguish one + instance from another or from him. Read it as a claim, never as a hands-off signal, and never let + it park an issue past the expiry below. The `agent-claim/` tip is the **cross-lane** signal; + lane work branches remain useful for within-lane discovery and for instances that cannot assign + (the Cursor cloud lane — see its loader). **Match on the issue NUMBER or a normalised stem — never the literal branch name.** On #96 two sessions collided on `claude/war-armour-…` versus `claude/war-armor-…`: the repo's code is American, the issue's title British, so each session derived a different stem from a different part @@ -582,83 +592,76 @@ touch of an unconfirmed repo: `gh pr list -R / --state open --search '"#" in:body'`. A bare number matches any body that merely contains it (a benchmark count, a date, another repo's issue number), which would hide the oldest actionable issue behind an unrelated PR. -2. **Claim before you build, not after.** The moment you select an issue, (a) self-assign it — - **if `devantler` is already assigned (a stale bare assignment from an abandoned run), remove and - re-add**, because the add is a no-op for an existing assignee and would leave your lease carrying - the *old* timestamp, so your fresh claim reads as expired the moment another run checks it — and - (b) push the branch — both cheap, visible and reversible — and only **then** harden (tests, - ablations, docs, comments). Opening the **draft PR after the first real commit** is stronger still - and is the recommended default. A pre-flight scan with no branch and no PR is **not** a claim. - **Put the issue number IN the claim branch name** — `claude/--` (e.g. - `claude/war-foliage-spatial-hash-109`). Before a PR exists there is no body to grep, so a bare - `claude/-` leaves a rival only the normalised-stem match that #96 proved fragile; the +2. **Claim before you build, not after — lane-neutral ref FIRST.** The moment you select an issue: + (a) **acquire `agent-claim/`** with the helper + (`.claude/scripts/agent-claim.sh acquire `) — this is the cross-lane race; a LOST exit means + stand down under rule 5; (b) self-assign it when your identity can + (**if `devantler` is already assigned, remove and re-add**, because the add is a no-op for an + existing assignee and would leave your lease carrying the *old* timestamp); and (c) push the + lane-specific work branch **with the issue number in its name** — + `/--` (e.g. `claude/war-foliage-spatial-hash-109`, + `cursor/agent-claim-ref-2302`). Only **then** harden (tests, ablations, docs, comments). Opening + the **draft PR after the first real commit** is stronger still and is the recommended default — + and **retires the `agent-claim/` tip** (rule 3). A pre-flight scan with no claim tip, no + branch and no PR is **not** a claim. Before a PR exists there is no body to grep, so a bare + `/-` leaves a rival only the normalised-stem match that #96 proved fragile; the number is the one token that cannot be spelled two ways. -3. **Claims expire, timed from the ASSIGNMENT.** A claim carrying no open PR after **~2 hours** is - stale and may be taken over, so a crashed or abandoned session never parks an issue permanently. - Measure that window from the issue's **NEWEST `assigned` timeline event**, or a long-lived issue - hands you a year-old assignment from page 1. Emit every match as its own line and take the max in - the shell — under `--paginate` each page is a **separate JSON array**, so an aggregate like - `'[…]|last'` runs *per page* and silently returns the last match of the final page, not the newest - overall (and `--slurp`, which would wrap the pages, is rejected alongside `--jq`): - ```sh - gh api repos///issues//timeline --paginate \ - --jq '.[]|select(.event=="assigned" and .assignee.login=="devantler")|.created_at' | sort | tail -1 - ``` - Filter to **`devantler`**: an issue can carry several assignees, and a later assignment of someone - else would otherwise set your lease clock — restarting a window you never renewed. - **Never measure from the branch's commit date** — a claim branch usually points at the base commit, - whose date is far older, so every fresh claim would read as long expired. If an issue is assigned - with no branch, or branched with no assignment, treat it as no claim at all. - **Taking over a stale claim: unassign, then re-assign.** Every instance uses the same `devantler` - login, and GitHub's add-assignees endpoint is a no-op for an already-assigned user — so a plain - re-assign creates **no new `assigned` event**, the lease keeps the dead claim's timestamp, and the - next run reads your fresh claim as already expired and races you. Clear it first — and **always - pass `-R /`**, because a run works from the monorepo checkout or a submodule worktree - and an unqualified `gh issue edit` resolves against *that* repo, silently editing whatever issue - happens to carry the same number: - ```sh - gh issue edit -R / --remove-assignee devantler - gh issue edit -R / --add-assignee devantler - ``` - so the takeover starts a genuinely new lease. **If the dead claim left a remote branch carrying - commits**, do not reuse that name: the deterministic name collides, and pushing onto it either gets - rejected or silently builds on abandoned work. Start a fresh branch - (`claude/---2`) and leave theirs alone — it is another instance's work, and the - branch-cleanup sweep reaps it once it is provably stale. Never force-push over it. This time-boxing is what keeps the rule compatible with - *"a bare assignee does not reserve an issue"* above: a claim is a short lease, not a lock. -4. **Re-verify immediately before the first push, and make that push DECIDE the race.** The residual - window is seconds wide but real (that is exactly how #88 and #96 were lost). Two instances picking - the same issue **within one lane** derive the *same* deterministic branch name, so a bare re-check - is not enough — both would see "no branch" and both would then believe they claimed it. Settle it - on the push (but see the cross-lane limit below, which this does **not** cover): - - Put a **real commit** on the claim branch (the first substantive change, or an empty - `git commit --allow-empty -m "chore: claim #"`), never a bare pointer at the base commit — - otherwise both pushes are trivially fast-forwards and neither is refused. - - Push **without force**, then **verify the remote tip is yours**: - `git ls-remote origin /--` must return **your** sha. **Compare the tip — - never judge the race by the push's exit status**, and never through a pipe: `git push … | tail` - reports `tail`'s status, so a *rejected* push reads as exit 0 (reproduced 2026-07-20). If the tip - is someone else's, **you lost the race** — stand down under rule 5 rather than force-pushing over - them. Never `--force`/`--force-with-lease` a claim branch: that is how a "won" race silently - destroys the winner's work. - - ⚠️ **KNOWN HOLE — this arbitration only works WITHIN one lane, not across lanes.** It depends on - both instances deriving the *same* ref so one push is refused, but each instance writes its own - namespace (`claude/*`, `codex/*`, `cursor/*`), so two *different* instances racing one issue both - push successfully and both believe they won. This is **pre-existing, not new** — `codex/*` has - been in live use alongside `claude/*` for some time (measured 2026-07-20: 17 such PRs on ksail, - 3 on world-at-ruin), so cross-lane races have never actually been arbitrated. A lane-neutral - claim ref fixes it, and the design plus its proofs are worked out in - [monorepo#2302](https://github.com/devantler-tech/monorepo/issues/2302); until that lands, rely - on the signals in rules 1–3 (open PRs, remote branches, assignees) and accept that a cross-lane - selection can still duplicate. **Worse for the cloud lane:** the surveyor's pre-PR branch scan - greps `claude/*` only *and* is gated on repos having an **assigned** PR-less issue — and - `app/cursor` cannot assign — so that instance's only pre-PR claim signal is currently invisible - to local runs, well beyond a simultaneous window. Check `cursor/*` branches by hand when - selecting until [monorepo#2300](https://github.com/devantler-tech/monorepo/issues/2300) lands. -5. **On a lost race, ABANDON.** Never duplicate the work, never force-push onto a sibling's branch, - never open a competing PR. Then **use the loss**: two independent implementations of one spec are - a free **differential-testing oracle**. Diff yours against the winner's and post **only findings - you have verified** — on w-a-r#88 that surfaced a real integer-overflow gap the merged twin shared. +3. **Claims expire; retire on PR open; stale takeover is evidence-gated.** A claim carrying no open + PR after **~2 hours** is stale and may be taken over, so a crashed or abandoned session never + parks an issue permanently. + - **Retire on PR open:** the moment the draft PR that references `#` exists, run + `agent-claim.sh retire ` so the shared tip cannot lock the issue after coordination has + succeeded. An unretired `agent-claim/*` tip is a **permanent lock** (nothing else sweeps that + namespace) — trap 4 of #2302; retirement is mandatory, not optional hygiene. + - **Lease clock for the shared tip** is the tip's **committer date** (the helper writes a fresh + commit at acquire time, so this is wall-clock accurate). Check with + `agent-claim.sh is-stale `. + - **Lease clock for the assignee** (when your identity can assign) remains the issue's **NEWEST + `assigned` timeline event** for `devantler` — never a work-branch commit date (those usually + point at the base commit and would make every fresh claim look long expired): + ```sh + gh api repos///issues//timeline --paginate \ + --jq '.[]|select(.event=="assigned" and .assignee.login=="devantler")|.created_at' | sort | tail -1 + ``` + Filter to **`devantler`**: an issue can carry several assignees, and a later assignment of + someone else would otherwise set your lease clock. Under `--paginate` each page is a **separate + JSON array**, so an aggregate like `'[…]|last'` runs *per page* — emit every match as its own + line and take the max in the shell. + - **Taking over a stale claim** needs BOTH evidence gates: (1) no open PR whose body references + `#`, and (2) the `agent-claim/` tip past the lease (`is-stale` exits 0). Then + `agent-claim.sh acquire --takeover`. Also unassign-then-re-assign when your identity can + assign (GitHub's add-assignees endpoint is a no-op for an already-assigned user — a plain + re-assign creates **no** new `assigned` event). **Always pass `-R /`**: + ```sh + gh issue edit -R / --remove-assignee devantler + gh issue edit -R / --add-assignee devantler + ``` + **If the dead claim left a remote *work* branch carrying commits**, do not reuse that name: + start a fresh branch (`/---2`) and leave theirs alone — never + force-push over it. This time-boxing keeps the rule compatible with *"a bare assignee does not + reserve an issue"*: a claim is a short lease, not a lock. +4. **Make the `agent-claim/` push DECIDE the race — compare the tip, never the exit status.** + The residual window is seconds wide but real (that is exactly how #88 and #96 were lost). Every + instance derives the *same* ref from the issue number, so a bare re-check is not enough — both + would see "no tip" and both would then believe they claimed it. Settle it on the helper's push: + - The helper writes a **fresh commit with a portable nonce** (`/dev/urandom` hex; **fail closed** + when no entropy source is available). A fixed message on a shared parent in the same second + yields a **byte-identical** commit — reproduced exactly on 2026-07-20 — so both pushes succeed + and both read the tip as theirs (trap 3). The nonce is the whole defence. + - Push **without force**, then **verify the remote tip is yours** (`agent-claim.sh verify + `, or `git ls-remote origin refs/heads/agent-claim/`). **Compare the tip — never + judge the race by the push's exit status**, and never through a pipe: `git push … | tail` + reports `tail`'s status, so a *rejected* push reads as exit 0 (reproduced 2026-07-20, trap 2). + If the tip is someone else's, **you lost the race** — stand down under rule 5 rather than + force-pushing over them. Never `--force`/`--force-with-lease` a live claim tip. + - After you hold the tip, push the lane work branch the same way (real commit, no force, tip + compare). The shared tip is what closes the cross-lane hole; the lane branch remains the + per-instance working ref. +5. **On a lost race, ABANDON.** Never duplicate the work, never force-push onto a sibling's branch + or claim tip, never open a competing PR. Then **use the loss**: two independent implementations of + one spec are a free **differential-testing oracle**. Diff yours against the winner's and post + **only findings you have verified** — on w-a-r#88 that surfaced a real integer-overflow gap the + merged twin shared. **How you verify depends on who won, and the trust gate is not relaxed here:** against a **trusted/routine-owned** winner, execute the probe on their branch; against an **external-contributor** winner, it is **static review ONLY** — never check out, build, run or @@ -668,13 +671,15 @@ touch of an unconfirmed repo: the merged armour guard's membership-vs-mapping gap was found). **A live claim is a temporary skip — the one addition to the skip test.** *Drain oldest-first* lists -when an older issue may be passed over; a **live claim** (assigned **and** branched, inside the ~2h -window, no PR yet) now joins it as skip reason **(e)**, and it is the only one that expires on its -own. Without that, an oldest issue carrying a fresh claim would be both un-takeable and un-skippable — -which either stalls the queue or recreates the duplicate build the protocol exists to prevent. Note it -in the report as claimed-elsewhere and move to the next actionable issue; if it is still branch-only -after the window, it is fair game again. **Nothing else in that test changes** — in particular, an -issue is never skipped merely because it *looks* contested, is large, or is hard. +when an older issue may be passed over; a **live claim** — an `agent-claim/` tip inside the +~2h lease **or** (assigned **and** branched, inside the ~2h window), with no PR yet — now joins it as +skip reason **(e)**, and it is the only one that expires on its own. Without that, an oldest issue +carrying a fresh claim would be both un-takeable and un-skippable — which either stalls the queue or +recreates the duplicate build the protocol exists to prevent. Note it in the report as +claimed-elsewhere and move to the next actionable issue; if it is still tip/branch-only after the +window, it is fair game again (evidence-gated takeover per rule 3). **Nothing else in that test +changes** — in particular, an issue is never skipped merely because it *looks* contested, is large, +or is hard. ### Professional-work repository boundary — hard exclusion Repositories connected to the maintainer's employment or professional obligations are