#!/usr/bin/env bash # Local work-order activity — advisory overlap visibility for concurrent agents. # Binding behavior: wiki/process/agent-scale.md slice A (amended 2026-07-10). # # Records retain the historical `.agents/claims/` path and `claim` command so # older worktrees keep interoperating. Path overlap warns but never blocks; # worktrees isolate edits and the landing phase reconciles against current main. # # Usage: # tools/claim.sh activity --class \ # [--key path]... [--force] # tools/claim.sh status [id] # tools/claim.sh list # tools/claim.sh set-status # tools/claim.sh release [--status done|abandoned] # tools/claim.sh check --key path [--key path]... # # Exit codes: 0 ok, 1 conflict/error, 2 bad usage. set -euo pipefail script_root=$(cd "$(dirname "$0")/.." && pwd) root=$script_root if [ -f "$script_root/.git" ]; then common=$(git -C "$script_root" rev-parse --path-format=absolute --git-common-dir) case "$common" in */.git) root=${common%/.git} ;; esac fi cd "$root" CLAIMS_DIR=${MISALIGNED_CLAIMS_DIR:-"$root/.agents/claims"} WORKTREE_ROOT=${MISALIGNED_WORKTREE_ROOT:-} if [ -n "$WORKTREE_ROOT" ]; then case "$WORKTREE_ROOT" in /*) ;; *) WORKTREE_ROOT="$root/$WORKTREE_ROOT" ;; esac fi ACTIVE_STATUSES="claimed blocked checking landing" usage() { sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' exit 2 } ensure_dir() { mkdir -p "$CLAIMS_DIR" } claim_path() { local id="$1" # Sanitize id to a single path segment. case "$id" in ""|*/*|*\\*|*..*) echo "FAIL: activity id must be a single path segment (got '$id')" >&2 exit 2 ;; esac printf '%s/%s.claim\n' "$CLAIMS_DIR" "$id" } is_active() { case "$1" in claimed|blocked|checking|landing) return 0 ;; *) return 1 ;; esac } # Read claim file into globals: c_id c_class c_status c_pid c_started c_host c_keys (array) read_claim() { local path="$1" line c_id=""; c_class=""; c_status=""; c_pid=""; c_started=""; c_host="" c_keys=() [ -f "$path" ] || return 1 while IFS= read -r line || [ -n "$line" ]; do case "$line" in id=*) c_id=${line#id=} ;; class=*) c_class=${line#class=} ;; status=*) c_status=${line#status=} ;; pid=*) c_pid=${line#pid=} ;; started=*) c_started=${line#started=} ;; host=*) c_host=${line#host=} ;; key=*) c_keys+=("${line#key=}") ;; esac done < "$path" return 0 } write_claim() { local path="$1" key { printf 'id=%s\n' "$c_id" printf 'class=%s\n' "$c_class" printf 'status=%s\n' "$c_status" printf 'pid=%s\n' "$c_pid" printf 'started=%s\n' "$c_started" printf 'host=%s\n' "$c_host" for key in "${c_keys[@]+"${c_keys[@]}"}"; do printf 'key=%s\n' "$key" done } > "$path" } keys_intersect() { # Args: proposed keys..., then --, then held keys... local -a proposed=() held=() local mode=proposed k p h for k in "$@"; do if [ "$k" = "--" ]; then mode=held continue fi if [ "$mode" = proposed ]; then proposed+=("$k") else held+=("$k") fi done # Empty proposed keys never conflict (docs-wide parallel ok). [ "${#proposed[@]}" -eq 0 ] && return 1 for p in "${proposed[@]}"; do for h in "${held[@]+"${held[@]}"}"; do if [ "$p" = "$h" ]; then return 0 fi case "$p:$h" in @*|*:@*) ;; *) p=${p%/}; h=${h%/} case "$p/" in "$h/"*) return 0 ;; esac case "$h/" in "$p/"*) return 0 ;; esac ;; esac done done return 1 } pid_alive() { local pid="$1" [ -n "$pid" ] && [ "$pid" -eq "$pid" ] 2>/dev/null && kill -0 "$pid" 2>/dev/null } linked_worktree_exists() { local id="$1" if [ -n "$WORKTREE_ROOT" ] && [ -d "$WORKTREE_ROOT/$id" ]; then return 0 fi # Letta, Claude, Codex, and the project wrapper use different parent # directories. Git's registry is the common truth; task worktree basenames # are the durable activity ids. git worktree list --porcelain 2>/dev/null | awk -v id="$id" ' $1 == "worktree" { path = $2 sub(/^.*\//, "", path) if (path == id) found = 1 } END { exit(found ? 0 : 1) } ' } # A helper command's pid is short-lived in agent environments. A linked task # worktree is the durable ownership signal; reap only when both pid and # worktree are gone. reap_stale() { local path id ensure_dir for path in "$CLAIMS_DIR"/*.claim; do [ -f "$path" ] || continue read_claim "$path" || continue is_active "$c_status" || continue if ! pid_alive "$c_pid"; then if linked_worktree_exists "$c_id"; then continue fi echo "activity: reaping stale record '$c_id' (dead pid $c_pid, was $c_status)" >&2 rm -f "$path" fi done } cmd_list() { ensure_dir reap_stale local path any=0 for path in "$CLAIMS_DIR"/*.claim; do [ -f "$path" ] || continue read_claim "$path" || continue any=1 printf '%s class=%s status=%s pid=%s paths=%s\n' \ "$c_id" "$c_class" "$c_status" "$c_pid" \ "$(IFS=,; echo "${c_keys[*]-}")" done if [ "$any" -eq 0 ]; then echo "(no active task activity in $CLAIMS_DIR)" fi } cmd_status() { local id="${1:-}" ensure_dir if [ -z "$id" ]; then cmd_list return fi local path path=$(claim_path "$id") if ! read_claim "$path"; then echo "FAIL: no activity record '$id' ($path)" >&2 exit 1 fi printf 'id=%s\nclass=%s\nstatus=%s\npid=%s\nstarted=%s\nhost=%s\n' \ "$c_id" "$c_class" "$c_status" "$c_pid" "$c_started" "$c_host" local k for k in "${c_keys[@]+"${c_keys[@]}"}"; do printf 'key=%s\n' "$k" done } find_conflict() { # Sets conflict_id conflict_keys on match. Args: proposed keys... local -a proposed=("$@") local path conflict_id="" conflict_keys="" for path in "$CLAIMS_DIR"/*.claim; do [ -f "$path" ] || continue read_claim "$path" || continue is_active "$c_status" || continue if keys_intersect "${proposed[@]+"${proposed[@]}"}" -- "${c_keys[@]+"${c_keys[@]}"}"; then conflict_id=$c_id conflict_keys=$(IFS=,; echo "${c_keys[*]-}") return 0 fi done return 1 } cmd_check() { ensure_dir reap_stale local -a keys=() while [ $# -gt 0 ]; do case "$1" in --key) shift; [ $# -gt 0 ] || usage; keys+=("$1"); shift ;; *) usage ;; esac done if find_conflict "${keys[@]+"${keys[@]}"}"; then echo "WARN: paths overlap active task '$conflict_id' (paths: $conflict_keys)" >&2 echo " overlap is advisory; finish in a worktree and reconcile at landing" >&2 exit 0 fi echo "check: ok (no active task advertises those paths)" } cmd_claim() { ensure_dir reap_stale local id="" class="" force=0 local -a keys=() id="${1:-}"; shift || true [ -n "$id" ] || usage while [ $# -gt 0 ]; do case "$1" in --class) shift; class="${1:-}"; shift || true ;; --key) shift; keys+=("${1:-}"); shift || true ;; --force) force=1; shift ;; *) usage ;; esac done case "$class" in docs|frontend|sim|save|process) ;; *) echo "FAIL: --class must be docs|frontend|sim|save|process" >&2 exit 2 ;; esac local path path=$(claim_path "$id") holder=${MISALIGNED_CLAIM_PID:-$PPID} if [ -f "$path" ]; then read_claim "$path" if is_active "$c_status"; then if [ "$force" -eq 1 ]; then echo "activity: --force replacing '$id' (was $c_status pid $c_pid)" elif pid_alive "$c_pid" && [ "$c_pid" = "$holder" ]; then : # same agent re-claim updates keys/status elif pid_alive "$c_pid"; then echo "WARN: replacing activity '$id' owned by pid $c_pid; task identity is shared" >&2 else echo "activity: replacing '$id' from dead pid $c_pid" fi fi fi if find_conflict "${keys[@]+"${keys[@]}"}" && [ "$conflict_id" != "$id" ]; then echo "WARN: paths overlap active task '$conflict_id'" >&2 echo " their paths: $conflict_keys" >&2 echo " your paths: $(IFS=,; echo "${keys[*]-}")" >&2 echo " both tasks may proceed in separate worktrees; reconcile at landing" >&2 fi c_id=$id c_class=$class c_status=claimed # Holder is the calling agent/shell (PPID), not this short-lived script. # Override with MISALIGNED_CLAIM_PID when a dispatcher wraps the tool. c_pid=$holder c_started=$(date -u +"%Y-%m-%dT%H:%M:%SZ") c_host=$(hostname 2>/dev/null || echo unknown) c_keys=("${keys[@]+"${keys[@]}"}") write_claim "$path" echo "activity: recorded '$id' class=$class status=claimed pid=$c_pid paths=$(IFS=,; echo "${c_keys[*]-}")" echo " file $path" } cmd_set_status() { local id="${1:-}" status="${2:-}" [ -n "$id" ] && [ -n "$status" ] || usage case "$status" in claimed|blocked|checking|landing|done|abandoned) ;; *) echo "FAIL: status must be claimed|blocked|checking|landing|done|abandoned" >&2 exit 2 ;; esac ensure_dir local path path=$(claim_path "$id") if ! read_claim "$path"; then echo "FAIL: no activity record '$id'" >&2 exit 1 fi if [ "$status" = "done" ] || [ "$status" = "abandoned" ]; then rm -f "$path" echo "activity: '$id' -> $status (cleared)" return 0 fi c_status=$status # Keep original holder pid; only refresh if explicitly set. if [ -n "${MISALIGNED_CLAIM_PID:-}" ]; then c_pid=$MISALIGNED_CLAIM_PID fi write_claim "$path" echo "activity: '$id' -> $status" } cmd_release() { local id="${1:-}" status=done shift || true [ -n "$id" ] || usage while [ $# -gt 0 ]; do case "$1" in --status) shift; status="${1:-done}"; shift || true ;; *) usage ;; esac done cmd_set_status "$id" "$status" } main() { local cmd="${1:-}" shift || true case "$cmd" in activity|claim) cmd_claim "$@" ;; list) cmd_list "$@" ;; status) cmd_status "$@" ;; set-status) cmd_set_status "$@" ;; release) cmd_release "$@" ;; check) cmd_check "$@" ;; -h|--help|help|"") usage ;; *) echo "unknown command: $cmd" >&2 usage ;; esac } main "$@"