pre-write-peer-guard.sh 2.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
  1. #!/bin/bash
  2. # hooks/pre-write-peer-guard.sh
  3. # PreToolUse(Edit|Write) — mid-session peer-writer guard (the real fix: collisions happen *during* a
  4. # session, not just at boot). Before I write a file, warn if that file was JUST modified by something
  5. # that isn't me this session — the signature of a live peer Claude session sharing this checkout.
  6. #
  7. # Fires only when ALL hold for the target file:
  8. # • it exists and is inside a git repo
  9. # • git sees it as modified/untracked (dirty)
  10. # • it was written in the last $THRESHOLD seconds (fresh — stale WIP is ignored)
  11. # • it is NOT in this session's touched-ledger (companion hooks/session-touched-ledger.sh)
  12. #
  13. # Advisory by default (exit 0 + additionalContext → fed to the model, does NOT block the write).
  14. # Set GUARD_BLOCK=1 to DENY the write instead. Never crashes the tool call. See worktree-boundaries.md.
  15. set -uo pipefail
  16. THRESHOLD=120 # seconds — "freshly modified" window
  17. INPUT=$(cat 2>/dev/null) || exit 0
  18. command -v jq >/dev/null 2>&1 || exit 0
  19. SID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
  20. FP=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
  21. [ -n "$FP" ] || exit 0
  22. [ -f "$FP" ] || exit 0 # new file → nothing to collide with yet
  23. DIR=$(dirname "$FP")
  24. git -C "$DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0 # not a repo → skip
  25. [ -n "$(git -C "$DIR" status --porcelain -- "$FP" 2>/dev/null)" ] || exit 0 # clean → no peer edit
  26. NOW=$(date +%s)
  27. M=$(stat -c %Y "$FP" 2>/dev/null || stat -f %m "$FP" 2>/dev/null)
  28. [ -n "${M:-}" ] || exit 0
  29. AGE=$(( NOW - M ))
  30. [ "$AGE" -lt "$THRESHOLD" ] || exit 0 # stale dirty (likely my own old WIP) → don't nag
  31. # Did I touch this file this session? Then it's mine → allow silently.
  32. LEDGER="$HOME/.claude/.session-touched/${SID}.list"
  33. if [ -n "$SID" ] && [ -f "$LEDGER" ] && grep -Fqx "$FP" "$LEDGER" 2>/dev/null; then
  34. exit 0
  35. fi
  36. # Dirty + fresh + not mine → a peer session likely just edited this file.
  37. MSG="PEER-WRITE ADVISORY: ${FP} was modified ${AGE}s ago and is not in your edit history this session"
  38. MSG="${MSG} — another Claude session may be editing this same checkout. RE-READ the file before writing"
  39. MSG="${MSG} (your old_string may be stale and you could clobber its work). If a peer is live, move your"
  40. MSG="${MSG} work to a separate worktree (git worktree add ../<dir> -b <branch>). See rules/worktree-boundaries.md."
  41. if [ "${GUARD_BLOCK:-0}" = "1" ]; then
  42. jq -n --arg r "$MSG" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
  43. else
  44. jq -n --arg c "$MSG" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$c}}'
  45. fi
  46. exit 0