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